diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f339b9f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +All notable changes to `@structupath/pi-steel` are documented here. The format +follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2026-08-23 + +### Added + +- **`steel-cutlist` skill** — deterministic 1D bar nesting for long products + (beams, channels, angles, HSS, tube, pipe). Packs required member lengths + onto purchasable mill lengths with explicit saw kerf and end-trim + allowances, a documented fit contract, and a strategy portfolio per + designation + grade group (mixed-stock greedy plus each single-stock-length + restriction) ranked by fewest unplaced members, least total stock length, + lowest known cost, then fewest bars. +- Independent post-placement cut-list verification (bar overcommitment, + material mismatch, duplicate or missing member instances) gating + publication; the per-bar `cutting_list.csv` is emitted only for verified, + fully placed runs. +- Member weights from explicit `unit_weight_plf` or the bundled AISC shape + database; unknown weights surface as warnings, never silent zeros. +- Bar diagrams (`layout.pdf`, `bars.png`), purchase summary by stock length, + drop candidates against a reusable threshold, and a versioned + `rfq_linear.json` handoff. +- **Pipeline integration** — `steel-estimate` now optimizes member items + (designation + length, no plate geometry) onto configurable mill lengths + (`--mill-lengths-ft`, default `40,50,60`; `--cutlist-kerf-in`, + `--end-trim-in`, `--min-drop-in`). Cut-list blockers, verifier findings, + and members longer than every mill length gate the run exactly like nest + blockers, publishing `cutlist_partial` diagnostics. +- **RFQ integration** — the draft workbook renders a + "LINEAR STOCK / CUT-LIST REFERENCE" section from a validated linear + handoff; the standalone `generate-rfq.py` accepts `--linear` with schema + and staleness validation against the estimate identity. +- `cutlist-result` schema (`1.0.0`) and the `cutlist_partial` / + `cutlist_verified` package statuses. +- **Linear stock in the canonical contract** — `stock` entries with + `stock_form: "linear"` model vendor-declared purchasable lengths and + on-hand sticks. Declared purchasable lengths replace the default mill + lengths for their designation + grade group; on-hand sticks require the + same hash-bound reviewer confirmation as on-hand plate and enter the + optimizer as finite, cost-free inventory. The portfolio ranks by + purchased length first, so confirmed sticks reduce buying whenever they + genuinely can, and purchase summaries, handoffs, and reports label + `on_hand` rows explicitly. + +## [0.2.3] - 2026-07-28 + +### Fixed + +- Corrected shape dataset ownership metadata in the provenance record. + +### Added + +- Pi gallery preview image, public demo recording, and public project guide. + +## [0.2.2] - 2026-07-28 + +### Added + +- Trustworthy estimate package pipeline: canonical estimate contract and + shared validation, grouped nest placement verification, deterministic draft + RFQ compilation, review-gated orchestration with immutable manifested runs, + and release gates for privacy and provenance. +- `steel-nest` plate nesting with guarded burn DXF output. + +## [0.1.0] - 2026-07-19 + +### Added + +- Initial release: `steel-takeoff` and `steel-rfq` skills for Pi. diff --git a/README.md b/README.md index 713f9d9..ebb4d5f 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![Pi package](https://img.shields.io/badge/Pi-package-7c3aed)](https://pi.dev/packages?name=pi-steel) Structural-steel estimating skills for the [Pi coding agent](https://pi.dev): -validated takeoffs, plate nesting, guarded DXF output, draft vendor RFQs, and a -review-gated estimate pipeline. +validated takeoffs, plate nesting, linear cut-list optimization, guarded DXF +output, draft vendor RFQs, and a review-gated estimate pipeline. Built by [StructuPath](https://structupath.ai). @@ -30,6 +30,7 @@ Then ask Pi: ```text Do a structural-steel takeoff from these drawings and build a validated BOM. Nest these rectangular plate parts on 96 × 48 stock. +Optimize these beam lengths onto 40/50/60 ft mill lengths. Prepare a draft RFQ from this estimate. ``` @@ -44,7 +45,8 @@ npm run doctor | Skill | Purpose | Primary outputs | | --- | --- | --- | | `steel-takeoff` | Validate member designations and calculate BOM weight and tonnage | Validated BOM and findings | -| `steel-nest` | Lay out plate parts with kerf, gap, and edge-margin controls | Nest results, cut list, reference drawings, and guarded burn DXFs | +| `steel-nest` | Lay out plate parts with kerf, gap, and edge-margin controls | Nest results, reference drawings, and guarded burn DXFs | +| `steel-cutlist` | Optimize member lengths onto purchasable mill lengths (1D bar nesting) | Verified cutting lists, purchase summaries, drop candidates, and bar diagrams | | `steel-rfq` | Compile estimate data into a reviewable vendor request | Draft `.xlsx` RFQ and semantic workbook record | | `steel-estimate` | Orchestrate the complete review-gated workflow | Immutable run directory, QA report, lineage, manifests, nesting, and draft RFQ | @@ -67,6 +69,15 @@ suppresses burn DXFs for the entire run and leaves clearly labeled estimating and reference artifacts for review. It does not emit G-code or claim machine-specific CAM compatibility. +### Linear cut-lists + +`steel-cutlist` packs member lengths (beams, channels, angles, HSS, tube, pipe) onto +purchasable mill lengths with explicit saw kerf and end-trim allowances. A +deterministic strategy portfolio per designation and grade minimizes unplaced +members, total stock length, and known cost, and every bar is independently +re-verified before a cutting list is published. Drops are reported as +candidates for review, never as certified reusable stock. + ### Draft RFQs `steel-rfq` groups material by stock family, carries approved nesting data into @@ -89,7 +100,7 @@ completed profile. ```text estimate input ↓ -contract validation → plate nesting → draft RFQ compilation +contract validation → plate nesting + linear cut-lists → draft RFQ compilation ↓ QA findings + lineage + immutable run manifest ↓ @@ -119,6 +130,7 @@ Keep operational inputs and generated artifacts outside the repository. See the - [GitHub wiki](https://github.com/StructuPath/pi-steel/wiki) - [Public data policy](PUBLIC_DATA_POLICY.md) - [Data provenance](DATA_PROVENANCE.md) +- [Changelog](CHANGELOG.md) - [Pi package catalog](https://pi.dev/packages?name=pi-steel) - [npm package](https://www.npmjs.com/package/@structupath/pi-steel) @@ -134,7 +146,7 @@ npm run provenance:check # shape-data integrity and recorded decision npm run release:check # complete release gate ``` -The current package version is `0.2.3`. `release:check` verifies the test, +The current package version is `0.3.0`. `release:check` verifies the test, privacy, package-content, shape-data integrity, ownership, license, and redistribution contracts before publication. diff --git a/package.json b/package.json index 9a5d005..073ccec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@structupath/pi-steel", - "version": "0.2.3", - "description": "Structural steel estimating for Pi — validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.", + "version": "0.3.0", + "description": "Structural steel estimating for Pi \u2014 validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.", "type": "module", "keywords": [ "pi-package", @@ -67,6 +67,7 @@ "DATA_PROVENANCE.json", "docs/assets/pi-steel-demo.gif", "docs/assets/pi-steel-gallery.webp", + "CHANGELOG.md", "README.md", "PUBLIC_DATA_POLICY.md", "LICENSE" diff --git a/pyproject.toml b/pyproject.toml index 3d985e1..3187f1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pi-steel-runtime" -version = "0.2.3" +version = "0.3.0" description = "Python runtime dependencies and test configuration for pi-steel" requires-python = ">=3.11,<3.14" dependencies = [ diff --git a/skills/_shared/pi_steel/__init__.py b/skills/_shared/pi_steel/__init__.py index 1a735a7..a4cc5e2 100644 --- a/skills/_shared/pi_steel/__init__.py +++ b/skills/_shared/pi_steel/__init__.py @@ -1,12 +1,18 @@ """Shared deterministic runtime primitives for pi-steel skills.""" -from .cli import StageArgumentParser, package_version, publish_failure_diagnostic +from .cli import ( + StageArgumentParser, + missing_optional_modules, + package_version, + publish_failure_diagnostic, +) from .contracts import ( ESTIMATE_PACKAGE_VERSION, ITEM_INTENTS, NEST_RESULT_VERSION, estimate_input_hash, instance_ids, + is_sha256, item_id_for, placement_ids, ) @@ -37,8 +43,10 @@ "canonical_json_bytes", "estimate_input_hash", "instance_ids", + "is_sha256", "item_id_for", "placement_ids", + "missing_optional_modules", "outcome_exit_code", "package_version", "publish_failure_diagnostic", diff --git a/skills/_shared/pi_steel/cli.py b/skills/_shared/pi_steel/cli.py index fdbcd2e..3f56911 100644 --- a/skills/_shared/pi_steel/cli.py +++ b/skills/_shared/pi_steel/cli.py @@ -3,10 +3,11 @@ from __future__ import annotations import argparse +import importlib.util import json import sys from pathlib import Path -from typing import Any +from typing import Any, Iterable from .run_manifest import ( ManifestError, @@ -84,6 +85,15 @@ def error(self, message): self.exit(1, f"{self.prog}: error: {message}{suffix}\n") +def missing_optional_modules(module_names: Iterable[str]) -> list[str]: + """Return the named optional modules unavailable to this interpreter.""" + return [ + name + for name in module_names + if importlib.util.find_spec(name) is None + ] + + def package_version(entry_file: str) -> str: package_path = Path(entry_file).resolve().parents[3] / "package.json" try: diff --git a/skills/_shared/pi_steel/contracts.py b/skills/_shared/pi_steel/contracts.py index 4ef62d8..447add9 100644 --- a/skills/_shared/pi_steel/contracts.py +++ b/skills/_shared/pi_steel/contracts.py @@ -21,6 +21,15 @@ ) +def is_sha256(value: Any) -> bool: + """Return whether a value is a lowercase hex SHA-256 digest.""" + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + def canonical_json_bytes(value: Any) -> bytes: return json.dumps( value, sort_keys=True, separators=(",", ":"), ensure_ascii=False diff --git a/skills/_shared/pi_steel/parsing.py b/skills/_shared/pi_steel/parsing.py index 5f7fb42..8838a18 100644 --- a/skills/_shared/pi_steel/parsing.py +++ b/skills/_shared/pi_steel/parsing.py @@ -15,6 +15,13 @@ ) +def normalize_designation(value: Any) -> str: + """Canonical AISC-style designation key: uppercase, no spaces.""" + if not isinstance(value, str): + return "" + return value.upper().replace(" ", "").strip() + + def parse_length_ft(raw: str) -> float: value = raw.strip() if "'" not in value: diff --git a/skills/_shared/pi_steel/run_manifest.py b/skills/_shared/pi_steel/run_manifest.py index 5a4107c..07dbf56 100644 --- a/skills/_shared/pi_steel/run_manifest.py +++ b/skills/_shared/pi_steel/run_manifest.py @@ -39,6 +39,8 @@ "validated", "nested_partial", "nest_verified", + "cutlist_partial", + "cutlist_verified", "rfq_draft_review_required", "rfq_ready_for_review", } diff --git a/skills/_shared/pi_steel/validation.py b/skills/_shared/pi_steel/validation.py index c3249ac..51a6d8b 100644 --- a/skills/_shared/pi_steel/validation.py +++ b/skills/_shared/pi_steel/validation.py @@ -445,6 +445,16 @@ def validate_estimate_package(package: dict[str, Any]) -> ValidationResult: ) for index, stock in enumerate(package.get("stock", [])): + if stock.get("stock_form") == "linear" and stock.get("unlimited"): + if stock.get("stock_kind") == "on_hand": + _add( + findings, + input_hash, + "unlimited_on_hand_stock", + "blocker", + f"$.stock[{index}].unlimited", + "On-hand stock must be a finite, measured quantity.", + ) if stock.get("stock_kind") != "on_hand": continue required = ( @@ -576,6 +586,41 @@ def eligible_on_hand_stock(package: dict[str, Any]) -> list[dict[str, Any]]: return eligible +def eligible_on_hand_linear_stock(package: dict[str, Any]) -> list[dict[str, Any]]: + """Confirmed, available on-hand linear sticks eligible to reduce purchasing.""" + input_hash = estimate_input_hash(package) + eligible = [] + for stock in package.get("stock", []): + confirmation = stock.get("reviewer_confirmation", {}) + if ( + stock.get("stock_form") == "linear" + and stock.get("stock_kind") == "on_hand" + and stock.get("inventory_id") + and stock.get("designation") + and stock.get("grade") + and finite_positive(stock.get("length_ft")) + and not stock.get("unlimited") + and stock.get("measured_at") + and stock.get("source") + and stock.get("status") == "available" + and confirmation.get("actor") + and confirmation.get("timestamp") + and confirmation.get("estimate_hash") == input_hash + ): + eligible.append(stock) + return eligible + + +def linear_purchasable_stock(package: dict[str, Any]) -> list[dict[str, Any]]: + """Vendor-declared purchasable linear stock lengths.""" + return [ + stock + for stock in package.get("stock", []) + if stock.get("stock_form") == "linear" + and stock.get("stock_kind") == "purchasable" + ] + + def purchasable_items(package: dict[str, Any]) -> list[dict[str, Any]]: return [ item diff --git a/skills/_shared/schemas/cutlist-result.schema.json b/skills/_shared/schemas/cutlist-result.schema.json new file mode 100644 index 0000000..693d5dc --- /dev/null +++ b/skills/_shared/schemas/cutlist-result.schema.json @@ -0,0 +1,211 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://structupath.ai/schemas/pi-steel/cutlist-result-1.0.0.json", + "title": "pi-steel linear cut-list result", + "type": "object", + "required": [ + "schema_version", + "algorithm_version", + "normalized_input_hash", + "estimate_input_hash", + "configuration_hash", + "outcome", + "package_status", + "meta", + "fit_contract", + "bars_used", + "metrics", + "bar_reports", + "purchase_summary", + "drops", + "unplaced", + "validation_findings", + "verification", + "cost", + "rfq_linear" + ], + "properties": { + "schema_version": { "const": "1.0.0" }, + "algorithm_version": { "type": "string", "minLength": 1 }, + "normalized_input_hash": { "$ref": "#/$defs/sha256" }, + "estimate_input_hash": { "$ref": "#/$defs/sha256" }, + "configuration_hash": { "$ref": "#/$defs/sha256" }, + "outcome": { + "enum": ["ready", "review_required", "blocked", "dependency_missing"] + }, + "package_status": { + "enum": ["draft", "cutlist_partial", "cutlist_verified"] + }, + "meta": { + "type": "object", + "required": [ + "job_name", + "project_id", + "revision_id", + "kerf_in", + "end_trim_in", + "min_drop_in", + "unit_system" + ] + }, + "fit_contract": { + "type": "object", + "required": [ + "usable_length", + "piece_fits_when", + "consumption_per_piece" + ] + }, + "bars_used": { "type": "integer", "minimum": 0 }, + "metrics": { + "type": "object", + "required": ["utilization_pct", "total_kerf_in"] + }, + "weight_status": { "enum": ["known", "incomplete"] }, + "cost": { + "type": "object", + "required": ["status", "total"], + "properties": { + "status": { + "enum": ["known", "not_provided", "incomplete_unplaced"] + }, + "total": { "type": ["number", "null"] } + } + }, + "bar_reports": { + "type": "array", + "items": { "$ref": "#/$defs/barReport" } + }, + "purchase_summary": { + "type": "array", + "items": { + "type": "object", + "required": [ + "stock_id", + "stock_kind", + "designation", + "grade", + "bar_length_in", + "bars_needed", + "total_length_ft", + "total_cost" + ], + "properties": { + "stock_kind": { "enum": ["purchasable", "on_hand"] } + } + } + }, + "drops": { + "type": "array", + "items": { + "type": "object", + "required": [ + "bar_index", + "stock_id", + "designation", + "grade", + "length_in", + "status" + ], + "properties": { + "status": { "const": "candidate_unverified" } + } + } + }, + "unplaced": { + "type": "array", + "items": { + "type": "object", + "required": [ + "item_id", + "label", + "designation", + "grade", + "length_in", + "quantity", + "reason" + ], + "properties": { + "reason": { + "enum": ["no_compatible_stock_fit", "stock_exhausted"] + } + } + } + }, + "verification": { + "type": "object", + "required": ["status", "findings"], + "properties": { + "status": { "enum": ["verified", "failed", "not_run"] } + } + }, + "rfq_linear": { + "type": "object", + "required": [ + "schema_version", + "source_cutlist_result_version", + "project_id", + "revision_id", + "estimate_input_hash", + "rows" + ], + "properties": { + "schema_version": { "const": "1.0.0" }, + "source_cutlist_result_version": { "const": "1.0.0" } + } + } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "barReport": { + "type": "object", + "required": [ + "index", + "stock_id", + "stock_kind", + "designation", + "grade", + "bar_length_in", + "usable_length_in", + "end_trim_in", + "cuts", + "num_cuts", + "cut_length_in", + "kerf_total_in", + "drop_in", + "drop_class", + "utilization_pct", + "bar_cost", + "cost_basis" + ], + "properties": { + "stock_kind": { "enum": ["purchasable", "on_hand"] }, + "drop_class": { "enum": ["reusable_candidate", "offcut"] }, + "cuts": { + "type": "array", + "items": { + "type": "object", + "required": [ + "sequence", + "item_id", + "source_id", + "instance_id", + "placement_id", + "label", + "designation", + "grade", + "length_in", + "weight_lbs", + "weight_basis" + ], + "properties": { + "weight_basis": { + "enum": ["declared", "aisc_database", "unknown"] + } + } + } + } + } + } + } +} diff --git a/skills/_shared/schemas/estimate-package.schema.json b/skills/_shared/schemas/estimate-package.schema.json index 4b01dc3..63e387d 100644 --- a/skills/_shared/schemas/estimate-package.schema.json +++ b/skills/_shared/schemas/estimate-package.schema.json @@ -23,7 +23,12 @@ }, "stock": { "type": "array", - "items": { "$ref": "#/$defs/stock" } + "items": { + "oneOf": [ + { "$ref": "#/$defs/stock" }, + { "$ref": "#/$defs/linearStock" } + ] + } }, "commercial_basis": { "$ref": "#/$defs/commercialBasis" }, "assumptions": { @@ -264,6 +269,41 @@ }, "additionalProperties": false }, + "linearStock": { + "type": "object", + "required": [ + "stock_kind", + "stock_form", + "designation", + "grade", + "length_ft", + "quantity" + ], + "properties": { + "stock_kind": { "enum": ["on_hand", "purchasable"] }, + "stock_form": { "const": "linear" }, + "inventory_id": { "type": "string", "minLength": 1 }, + "designation": { "type": "string", "minLength": 1 }, + "grade": { "type": "string", "minLength": 1 }, + "length_ft": { "type": "number", "exclusiveMinimum": 0 }, + "quantity": { "type": "integer", "minimum": 1 }, + "unlimited": { "type": "boolean" }, + "status": { "enum": ["available", "reserved", "unavailable"] }, + "measured_at": { "type": "string", "format": "date" }, + "source": { "type": "string" }, + "reviewer_confirmation": { + "type": "object", + "required": ["actor", "timestamp", "estimate_hash"], + "properties": { + "actor": { "type": "string", "minLength": 1 }, + "timestamp": { "type": "string", "format": "date-time" }, + "estimate_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "commercialBasis": { "type": "object", "required": ["currency", "costs"], diff --git a/skills/_shared/schemas/nest-result.schema.json b/skills/_shared/schemas/nest-result.schema.json index e141a07..323b1af 100644 --- a/skills/_shared/schemas/nest-result.schema.json +++ b/skills/_shared/schemas/nest-result.schema.json @@ -55,6 +55,8 @@ "validated", "nested_partial", "nest_verified", + "cutlist_partial", + "cutlist_verified", "rfq_draft_review_required", "rfq_ready_for_review" ] diff --git a/skills/steel-cutlist/SKILL.md b/skills/steel-cutlist/SKILL.md new file mode 100644 index 0000000..ce35421 --- /dev/null +++ b/skills/steel-cutlist/SKILL.md @@ -0,0 +1,88 @@ +--- +name: steel-cutlist +description: "Optimize cut lengths of beams, channels, angles, HSS, tube, pipe, and any long product purchased by length — the 1D bar-nesting step. Use this skill whenever someone mentions cutting stock lengths, bar optimization, mill lengths, how many sticks/bars/lengths to buy, member cut lists, saw schedules, drops, or minimizing offcut waste on linear material. Produces a verified per-bar cutting plan, purchase summary by stock length, drop candidates, utilization, member weights, and optional cost totals only when an explicit basis exists." +--- + +# Steel Linear Cut-List Optimization + +## What This Skill Does + +This is the length-optimization step for long products: it packs required member lengths onto purchasable stock bars (mill lengths), accounting for saw kerf and end trim, then independently verifies every bar before publishing a cutting list. It answers "how many sticks do I buy, at which lengths, and how do I cut them" with a repeatable, reviewable result. + +It complements `steel-nest` (2D plates). Plates go to `steel-nest`; anything bought by the foot goes here. + +## What It Does Well vs. What It Doesn't + +**Reliable:** +- Exact 1D packing per designation + grade group. Stock never crosses groups: a W12X26 member is only cut from W12X26 stock of the same grade. +- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — ranked by fewest unplaced members, least purchased stock length (on-hand consumption is free), lowest known purchase cost, fewest purchased bars, then least total length. The same input always produces the same plan. +- Explicit fit contract: usable length = bar length − 2 × end trim; a piece fits when its length alone fits the remainder; each placed piece then consumes its length plus one kerf, saturating at the bar end. +- Independent post-placement verification (bar overcommitment, material mismatch, duplicate or missing instances) before any cutting list is published. +- Drops classified against a reusable-candidate threshold (`min_drop_in`) — candidates are never certified reusable stock. +- Member weights from explicit `unit_weight_plf` or the bundled AISC shape database; unknown weights are a visible warning, never a silent zero. +- Optional cost by one explicit basis per stock entry (`cost_per_ft` or `cost_per_bar`, never both). +- Labeled bar diagrams (PDF + PNG) and a per-bar `cutting_list.csv` emitted only for fully verified, fully placed runs. + +**Deliberately NOT done:** +- No saw-controller programs or claims of machine-specific compatibility; the cutting list is a shop document that an operator verifies. +- No remnant-inventory or scrap-market optimization. Drop candidates need a person to measure, identify, and approve before they become stock. +- No true global optimum guarantee — the portfolio heuristic is strong and deterministic, but it is a heuristic; say so if asked. + +## Inputs to Gather + +Everything drives a single job JSON (schema in `references/job_template.json`; a worked example in `references/example_job.json`). + +1. **Members** — for each line: name/mark, designation (e.g. `W12X26`, `HSS6X6X1/2`, `L4X4X1/4`), grade, required length (exactly one of `length_in` or `length_ft`; `length_ft` accepts `28.5` or `"28'-6"`), and quantity. Optional `unit_weight_plf` overrides the AISC lookup. +2. **Stock** — for each purchasable bar length: designation, grade, length (`length_in` or `length_ft`), and finite `qty` or `"unlimited": true`. Common mill lengths: 20, 25, 30, 35, 40, 45, 50, 55, 60 ft. A price is optional; if provided, use exactly one basis (`cost_per_ft` or `cost_per_bar`). +3. **Cut settings** — `kerf_in` (band saw ~0.06", cold saw ~0.09", abrasive/miter ~0.19"), `end_trim_in` per bar end (mill-end cleanup, default 0.25"), and `min_drop_in` (drops at or above this length are reported as reusable candidates, default 24"). + +If a takeoff/BOM spreadsheet is provided, map its columns to member fields and confirm the interpretation before running. Do not silently guess quantities or lengths. + +## How to Run + +Write the job JSON, then run the engine: + +```bash +python3 scripts/cutlist.py --job --out +``` + +The designation, grade, and imperial unit basis must be explicit; the engine never infers them from display names. `` is a publication root: every invocation creates `/runs//` and atomically updates `/latest-run.json`; follow that pointer to find the current run. + +Each run contains: + +- `run-manifest.json` and `qa-report.json` — outcome, readiness, hashes, warnings, and the exact artifact allow-list +- `layout.pdf` — every bar drawn with its cuts and drop, plus a summary page (the main deliverable) +- `bars.png` — the bar diagram as one image +- `cutting_list.csv` — the per-bar cut sequence; present only when the run is `ready` (verified and fully placed) +- `rfq_linear.json` — versioned `1.0.0` linear-stock rows for the `steel-rfq` hand-off; absent on blocked runs +- `report.txt` — the text report +- `result.json` — schema-versioned result with normalized input/configuration hashes, algorithm version, bar reports, verifier findings, purchase summary, drops, and cost status + +Exit meanings: + +- `0` — `ready`; every member is placed and the plan passed independent verification +- `3` — `blocked`; validation errors or unplaced members prevented a cutting list +- `4` — a required runtime capability is missing +- `1` — usage or internal error + +Install the declared dependencies from the package root with `python3 -m pip install -r requirements-dev.txt`. + +## What to Deliver + +Always deliver the **PDF layout** and give the headline numbers in the message: bars to buy by length, length utilization, drop candidates, weight status, and cost status. Attach `cutting_list.csv` when it exists and state that saw-operator verification is still required. + +Verify before presenting: require `verification.status = verified`, reconcile known cost to its recorded per-foot or per-bar basis, and never turn a missing or incomplete cost into `$0`. If members did not fit, lead with what is unplaced and why — never present a partial plan as complete. + +## Integration with steel-rfq + +The engine writes `rfq_linear.json` — `{schema_version, source_cutlist_result_version, rows}` with one row per stock identity: bars needed, bar length, total feet, cutting plan, and drop notes. Rows stay separate by stock identity even when display names match. Resolve the current run through `latest-run.json` and reject unknown handoff versions. + +## Common Variations + +**Mixed designations in one order** — list them all; the engine forms independent designation + grade groups and optimizes each on its own stock. + +**"Just tell me how many sticks"** — still run it; the purchase summary is the answer. Cost remains absent unless an explicit basis is supplied. + +**Drop reuse** — output drops are candidates only. Measure, identify, and approve a candidate before supplying it as its own finite stock entry in a later run (a shorter `length_in` entry with `qty: 1`). + +**On-hand material first** — enter on-hand bars as finite-quantity stock entries with `"stock_kind": "on_hand"` alongside purchasable lengths. On-hand stock must be finite and carries no cost basis; the portfolio minimizes purchased length first, so sticks are consumed whenever they genuinely reduce buying, and every report labels on-hand rows explicitly. diff --git a/skills/steel-cutlist/references/FIXTURE_PROVENANCE.md b/skills/steel-cutlist/references/FIXTURE_PROVENANCE.md new file mode 100644 index 0000000..bf8b87c --- /dev/null +++ b/skills/steel-cutlist/references/FIXTURE_PROVENANCE.md @@ -0,0 +1,13 @@ +# Fixture Provenance + +`example_job.json` and `job_template.json` are synthetic public examples created +from scratch for pi-steel documentation and tests. They are not copied, transformed, +rounded, renamed, or anonymized from a company, customer, vendor, bid, drawing, +takeoff, inventory record, or production job. Designations are standard public +AISC shape names; prices are invented round numbers. + +- Creator: StructuPath pi-steel maintainers +- Creation method: deliberately invented lengths, quantities, and identifiers +- Public-data review: 2026-08-23 +- Commercial data: none +- Private source artifacts: none diff --git a/skills/steel-cutlist/references/example_job.json b/skills/steel-cutlist/references/example_job.json new file mode 100644 index 0000000..a7d4645 --- /dev/null +++ b/skills/steel-cutlist/references/example_job.json @@ -0,0 +1,17 @@ +{ + "job_name": "SYNTHETIC-CUTLIST", + "project_id": "SYNTH-PRJ", + "revision_id": "REV-A", + "unit_system": "imperial", + "settings": {"kerf_in": 0.125, "end_trim_in": 0.25, "min_drop_in": 24}, + "members": [ + {"source_id": "S1", "name": "B1", "designation": "W12X26", "grade": "A992", "length_ft": 28.5, "qty": 4}, + {"source_id": "S2", "name": "B2", "designation": "W12X26", "grade": "A992", "length_ft": "12'-6", "qty": 6}, + {"source_id": "S3", "name": "C1", "designation": "HSS6X6X1/2", "grade": "A500B", "length_in": 174, "qty": 3, "unit_weight_plf": 35.24} + ], + "stock": [ + {"stock_id": "STK-40", "designation": "W12X26", "grade": "A992", "length_ft": 40, "unlimited": true, "cost_per_ft": 31.2}, + {"stock_id": "STK-50", "designation": "W12X26", "grade": "A992", "length_ft": 50, "unlimited": true, "cost_per_ft": 39.0}, + {"stock_id": "STK-HSS", "designation": "HSS6X6X1/2", "grade": "A500B", "length_ft": 48, "qty": 2, "cost_per_bar": 1900} + ] +} diff --git a/skills/steel-cutlist/references/job_template.json b/skills/steel-cutlist/references/job_template.json new file mode 100644 index 0000000..803f98b --- /dev/null +++ b/skills/steel-cutlist/references/job_template.json @@ -0,0 +1,53 @@ +{ + "_comment": "steel-cutlist job template. Copy, fill in, and delete the _comment keys. Lengths: provide exactly one of length_in or length_ft per entry; length_ft accepts a number (28.5) or a feet-inches string (\"28'-6\").", + "job_name": "JOB NAME", + "customer": "", + "project_id": "PROJECT-ID", + "revision_id": "REV-0", + "unit_system": "imperial", + "settings": { + "_comment": "kerf_in: band saw ~0.06, cold saw ~0.09, abrasive ~0.19. end_trim_in applies per bar end. Drops >= min_drop_in are reported as reusable candidates.", + "kerf_in": 0.125, + "end_trim_in": 0.25, + "min_drop_in": 24 + }, + "members": [ + { + "source_id": "STABLE-SOURCE-ID", + "name": "B1", + "designation": "W12X26", + "grade": "A992", + "length_ft": 28.5, + "qty": 4 + }, + { + "_comment": "unit_weight_plf is optional; when omitted the bundled AISC database is consulted by designation.", + "source_id": "STABLE-SOURCE-ID-2", + "name": "C1", + "designation": "HSS6X6X1/2", + "grade": "A500B", + "length_in": 174, + "qty": 3, + "unit_weight_plf": 35.24 + } + ], + "stock": [ + { + "stock_id": "STK-W12X26-40", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "unlimited": true, + "cost_per_ft": 31.2 + }, + { + "_comment": "Finite quantities model on-hand material or vendor availability. Use exactly one cost basis: cost_per_ft or cost_per_bar.", + "stock_id": "STK-HSS-48", + "designation": "HSS6X6X1/2", + "grade": "A500B", + "length_ft": 48, + "qty": 2, + "cost_per_bar": 1900 + } + ] +} diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py new file mode 100644 index 0000000..bb6de98 --- /dev/null +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -0,0 +1,1522 @@ +#!/usr/bin/env python3 +""" +Steel linear cut-list engine (1D bar nesting for members) +========================================================== +Deterministic length optimization for long products: beams, channels, angles, +HSS, tube, pipe, bar, and any stock purchased by length and cut to member +lengths. + +Reliable: + * Packs required member lengths onto stock bars with a best-fit-decreasing + heuristic plus simulated new-bar selection across multiple stock lengths. + * Explicit saw kerf per cut and end-trim allowance per bar end. + * Independent post-placement verification re-checks every bar against the + declared fit model before any cutting list is published. + * Reports drops (with a reusable-candidate threshold), utilization, member + weights from explicit plf values or the bundled AISC shape database, and + optional reconciled purchase cost. + * Labeled bar diagrams (PNG + combined PDF) and a per-bar cutting list CSV + that is only emitted for fully verified, fully placed runs. + +Deliberately NOT done: + * No cut sequencing for a specific saw controller and no claim of + machine-specific compatibility; the cutting list is a shop document. + * No scrap-market or remnant-inventory optimization. Drops are reported as + unverified candidates for a person to disposition. + +Fit model (documented contract): + * usable bar length = stock length - 2 x end_trim_in + * a piece fits when its length alone fits in the remaining usable length + * each placed piece then consumes its length plus one kerf width, + saturating at the bar end (the final cut coincides with the end trim) + +Usage: + python3 cutlist.py --job job.json --out published/ + +The output root receives isolated runs// directories plus a +latest-run.json pointer. Exit 0 is ready, 2 requires review, and 3 is blocked. +""" + +import argparse +import csv +import io +import json +import math +import os +import sys +from collections import defaultdict +from pathlib import Path + + +SHARED_ROOT = Path(__file__).resolve().parents[2] / "_shared" +if str(SHARED_ROOT) not in sys.path: + sys.path.insert(0, str(SHARED_ROOT)) +from bootstrap import bootstrap_shared # noqa: E402 + +SKILLS_ROOT = bootstrap_shared(__file__) +from pi_steel import ( # noqa: E402 + RunPublisher, + StageArgumentParser, + canonical_json_bytes, + is_sha256, + item_id_for, + missing_optional_modules, + outcome_exit_code, + package_version, + placement_ids, + publish_failure_diagnostic, + sha256_bytes, +) +from pi_steel.contracts import content_hash, fallback_source_id, instance_ids # noqa: E402 +from pi_steel.parsing import normalize_designation, parse_length_ft # noqa: E402 + +CUTLIST_RESULT_VERSION = "1.0.0" +CUTLIST_ALGORITHM_VERSION = "portfolio-bfd-v1" +EPS = 1e-6 +AISC_DATABASE_RELATIVE = Path("steel-takeoff") / "assets" / "aisc-shapes-database.json" + + +def load_unit_weights(): + """Map normalized designation -> weight_per_ft from the bundled AISC data.""" + database_path = SKILLS_ROOT / AISC_DATABASE_RELATIVE + try: + rows = json.loads(database_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + weights = {} + for row in rows: + designation = normalize_designation(row.get("designation", "")) + weight = row.get("weight_per_ft") + if designation and isinstance(weight, (int, float)) and weight > 0: + weights[designation] = float(weight) + return weights + + +def _validation_finding(code, path, message, severity="error"): + return { + "code": code, + "severity": severity, + "path": path, + "message": message, + } + + +def _finite_positive(value): + return isinstance(value, (int, float)) and math.isfinite(value) and value > 0 + + +# -------------------------------------------------------------------------- +# Normalization +# -------------------------------------------------------------------------- +def _length_in(entry, path, findings): + """Resolve an explicit imperial length from length_in or length_ft.""" + has_inches = entry.get("length_in") is not None + has_feet = entry.get("length_ft") is not None + if has_inches == has_feet: + findings.append( + _validation_finding( + "ambiguous_length_basis", + path, + "Provide exactly one of length_in or length_ft.", + ) + ) + return 0.0 + try: + if has_inches: + parsed = float(entry["length_in"]) + else: + raw = entry["length_ft"] + parsed = ( + parse_length_ft(raw) if isinstance(raw, str) else float(raw) + ) * 12.0 + except (TypeError, ValueError): + parsed = math.nan + if not math.isfinite(parsed) or parsed <= 0: + findings.append( + _validation_finding( + "invalid_length", + path, + "Length must be finite and greater than zero.", + ) + ) + return 0.0 + return parsed + + +def normalize_job(job): + """Normalize and validate the direct-use JSON before any placement.""" + findings = [] + settings = job.get("settings", {}) + + def number(value, path, *, positive=False, nonnegative=False): + try: + parsed = float(value) + except (TypeError, ValueError): + parsed = math.nan + valid = math.isfinite(parsed) + if positive: + valid = valid and parsed > 0 + if nonnegative: + valid = valid and parsed >= 0 + if not valid: + findings.append( + _validation_finding( + "invalid_numeric_input", + path, + "Value must be finite" + + (" and greater than zero." if positive else " and non-negative."), + ) + ) + return 0.0 + return parsed + + kerf = number(settings.get("kerf_in", 0.125), "$.settings.kerf_in", nonnegative=True) + end_trim = number( + settings.get("end_trim_in", 0.25), + "$.settings.end_trim_in", + nonnegative=True, + ) + min_drop = number( + settings.get("min_drop_in", 24.0), + "$.settings.min_drop_in", + nonnegative=True, + ) + unit_system = job.get("unit_system") + if unit_system is None: + findings.append( + _validation_finding( + "missing_unit_basis", + "$.unit_system", + "The cut-list engine requires an explicit imperial unit basis.", + ) + ) + elif unit_system != "imperial": + findings.append( + _validation_finding( + "unsupported_unit_system", + "$.unit_system", + "The cut-list engine currently requires imperial units.", + ) + ) + + project_id = job.get("project_id") or job.get("job_name") or "LEGACY-CUTLIST" + revision_id = job.get("revision_id", "LEGACY-REVISION") + for identity_field, value in ( + ("project_id", project_id), + ("revision_id", revision_id), + ): + if not isinstance(value, str) or not value: + findings.append( + _validation_finding( + f"invalid_{identity_field}", + f"$.{identity_field}", + f"{identity_field} must be a non-empty string.", + ) + ) + if identity_field == "project_id": + project_id = "LEGACY-CUTLIST" + else: + revision_id = "LEGACY-REVISION" + estimate_input_hash = job.get("estimate_input_hash") + if estimate_input_hash is not None and not is_sha256(estimate_input_hash): + findings.append( + _validation_finding( + "invalid_estimate_input_hash", + "$.estimate_input_hash", + "Estimate input hash must be a lowercase SHA-256 value.", + ) + ) + estimate_input_hash = None + + default_grade = job.get("grade") + unit_weights = load_unit_weights() + members = [] + for index, member in enumerate(job.get("members", [])): + path = f"$.members[{index}]" + designation = normalize_designation(member.get("designation", "")) + if not designation: + findings.append( + _validation_finding( + "missing_designation", + f"{path}.designation", + "Member designation is required for stock compatibility.", + ) + ) + grade = member.get("grade", default_grade) + if not grade: + findings.append( + _validation_finding( + "missing_material_basis", + f"{path}.grade", + "Member grade must be explicit before placement.", + ) + ) + length = _length_in(member, path, findings) + try: + quantity = int(member.get("qty", 1)) + quantity_valid = quantity > 0 and quantity == float(member.get("qty", 1)) + except (TypeError, ValueError): + quantity, quantity_valid = 0, False + if not quantity_valid: + findings.append( + _validation_finding( + "invalid_quantity", f"{path}.qty", "Quantity must be a positive integer." + ) + ) + quantity = 0 + unit_weight = member.get("unit_weight_plf") + weight_basis = "declared" + if unit_weight is not None: + unit_weight = number( + unit_weight, f"{path}.unit_weight_plf", positive=True + ) + elif designation in unit_weights: + unit_weight = unit_weights[designation] + weight_basis = "aisc_database" + else: + weight_basis = "unknown" + findings.append( + _validation_finding( + "unknown_unit_weight", + f"{path}.unit_weight_plf", + ( + f"No unit weight declared and {designation or 'the designation'} " + "is not in the bundled AISC data; weights are incomplete." + ), + severity="warning", + ) + ) + explicit_source = member.get("source_id") + source_id = explicit_source or fallback_source_id( + revision_id, + { + key: member.get(key) + for key in ("name", "designation", "grade", "length_in", "length_ft") + }, + ) + item_id = member.get("item_id") or item_id_for( + project_id, revision_id, source_id + ) + members.append( + { + "source_id": source_id, + "item_id": item_id, + "label": member.get("name", source_id), + "designation": designation, + "grade": grade, + "length_in": length, + "quantity": quantity, + "unit_weight_plf": unit_weight, + "weight_basis": weight_basis, + } + ) + + stock_types = [] + for index, stock in enumerate(job.get("stock", [])): + path = f"$.stock[{index}]" + stock_kind = stock.get("stock_kind", "purchasable") + if stock_kind not in {"purchasable", "on_hand"}: + findings.append( + _validation_finding( + "invalid_stock_kind", + f"{path}.stock_kind", + "Stock kind must be purchasable or on_hand.", + ) + ) + stock_kind = "purchasable" + designation = normalize_designation(stock.get("designation", "")) + if not designation: + findings.append( + _validation_finding( + "missing_designation", + f"{path}.designation", + "Stock designation is required for member compatibility.", + ) + ) + grade = stock.get("grade", default_grade) + if not grade: + findings.append( + _validation_finding( + "missing_material_basis", + f"{path}.grade", + "Stock grade must be explicit.", + ) + ) + length = _length_in(stock, path, findings) + usable = length - 2 * end_trim + if length > 0 and usable <= EPS: + findings.append( + _validation_finding( + "unusable_stock_length", + f"{path}", + "End trim consumes the entire stock length.", + ) + ) + unlimited = stock.get("unlimited", False) + if not isinstance(unlimited, bool): + findings.append( + _validation_finding( + "invalid_unlimited_flag", + f"{path}.unlimited", + "Unlimited must be a JSON boolean.", + ) + ) + unlimited = False + if unlimited and stock_kind == "on_hand": + findings.append( + _validation_finding( + "unlimited_on_hand_stock", + f"{path}.unlimited", + "On-hand stock must be a finite, measured quantity.", + ) + ) + try: + quantity = math.inf if unlimited else int(stock.get("qty", 1)) + quantity_valid = unlimited or ( + quantity >= 0 and quantity == float(stock.get("qty", 1)) + ) + except (TypeError, ValueError): + quantity, quantity_valid = 0, False + if not quantity_valid: + findings.append( + _validation_finding( + "invalid_stock_quantity", + f"{path}.qty", + "Stock quantity must be a non-negative integer or unlimited.", + ) + ) + per_foot = stock.get("cost_per_ft") + per_bar = stock.get("cost_per_bar") + if per_foot is not None and per_bar is not None: + findings.append( + _validation_finding( + "conflicting_cost_basis", + path, + "Use either cost_per_ft or cost_per_bar for one stock entry, not both.", + ) + ) + if stock_kind == "on_hand" and (per_foot is not None or per_bar is not None): + findings.append( + _validation_finding( + "cost_basis_on_hand_stock", + path, + "On-hand stock carries no purchase cost basis; cost applies to purchasable stock only.", + ) + ) + if per_foot is not None: + per_foot = number(per_foot, f"{path}.cost_per_ft", nonnegative=True) + if per_bar is not None: + per_bar = number(per_bar, f"{path}.cost_per_bar", nonnegative=True) + stock_id = stock.get("stock_id") or ( + "stock:" + + content_hash( + { + "name": stock.get("name", "Bar"), + "stock_kind": stock_kind, + "designation": designation, + "grade": grade, + "length_in": length, + } + )[:24] + ) + stock_types.append( + { + "stock_id": stock_id, + "name": stock.get("name", designation or "Bar"), + "stock_kind": stock_kind, + "designation": designation, + "grade": grade, + "length_in": length, + "usable_in": max(usable, 0.0), + "qty": quantity, + "cost_per_ft": per_foot, + "cost_per_bar": per_bar, + "used": 0, + } + ) + + for collection_name, values, identity_field in ( + ("members", members, "item_id"), + ("stock", stock_types, "stock_id"), + ): + seen = {} + for index, value in enumerate(values): + identity = value[identity_field] + if identity in seen: + findings.append( + _validation_finding( + f"duplicate_{identity_field}", + f"$.{collection_name}[{index}].{identity_field}", + ( + f"{identity_field} duplicates row {seen[identity]}; " + "indistinguishable rows are not merged." + ), + ) + ) + else: + seen[identity] = index + members.sort(key=lambda member: member["item_id"]) + stock_types.sort(key=lambda stock: stock["stock_id"]) + if not members: + findings.append( + _validation_finding( + "missing_members", "$.members", "At least one member is required." + ) + ) + if not stock_types: + findings.append( + _validation_finding( + "missing_stock", "$.stock", "At least one stock entry is required." + ) + ) + normalized = { + "job_name": job.get("job_name", "Cut-list job"), + "customer": job.get("customer", ""), + "project_id": project_id, + "revision_id": revision_id, + "estimate_input_hash": estimate_input_hash, + "unit_system": unit_system or "unspecified", + "settings": { + "kerf_in": kerf, + "end_trim_in": end_trim, + "min_drop_in": min_drop, + }, + "members": members, + "stock": [ + { + key: ("unlimited" if key == "qty" and math.isinf(value) else value) + for key, value in stock.items() + if key != "used" + } + for stock in stock_types + ], + } + return normalized, stock_types, findings + + +# -------------------------------------------------------------------------- +# Placement engine +# -------------------------------------------------------------------------- +def _group_key(value): + return value["designation"], value["grade"] + + +def _simulate_fill(usable, kerf, queue): + """Greedy first-fit of a sorted queue onto one bar; returns used length.""" + remaining = usable + used = 0.0 + for length in queue: + if length <= remaining + EPS: + used += length + remaining -= length + kerf + if remaining < 0: + remaining = 0.0 + return used + + +def _place_on_bar(bar, unit, kerf): + bar["cuts"].append(unit) + bar["remaining"] -= unit["length_in"] + kerf + if bar["remaining"] < 0: + bar["remaining"] = 0.0 + + +def _greedy_pack(units, stocks, kerf, classification_stock): + """Best-fit-decreasing over open bars; new bars open by simulated fill. + + ``stocks`` restricts which stock this attempt may open; unplaceable + reasons are classified against ``classification_stock`` (the full group) + so restricted attempts never mislabel a genuinely placeable member. + """ + used = {stock["stock_id"]: 0 for stock in stocks} + bars = [] + unplaced = [] + pending = [unit["length_in"] for unit in units] + + def open_bar(unit): + best = None + best_score = None + for stock in stocks: + if used[stock["stock_id"]] >= stock["qty"]: + continue + if unit["length_in"] > stock["usable_in"] + EPS: + continue + fill = _simulate_fill(stock["usable_in"], kerf, pending) + fill_ratio = fill / stock["usable_in"] if stock["usable_in"] else 0.0 + score = (round(fill_ratio, 9), round(fill, 6), stock["stock_id"]) + if best_score is None or score > best_score: + best_score = score + best = stock + if best is None: + return None + used[best["stock_id"]] += 1 + bar = {"stock": best, "cuts": [], "remaining": best["usable_in"]} + bars.append(bar) + return bar + + for unit in units: + fits_group = any( + unit["length_in"] <= stock["usable_in"] + EPS + for stock in classification_stock + ) + fits_attempt = any( + unit["length_in"] <= stock["usable_in"] + EPS for stock in stocks + ) + if not fits_group or not fits_attempt: + reason = "no_compatible_stock_fit" if not fits_group else "stock_exhausted" + unplaced.append({**unit, "reason": reason}) + pending.remove(unit["length_in"]) + continue + best_bar = None + best_leftover = math.inf + for bar in bars: + if unit["length_in"] > bar["remaining"] + EPS: + continue + leftover = bar["remaining"] - unit["length_in"] + if leftover < best_leftover - EPS: + best_leftover = leftover + best_bar = bar + if best_bar is None: + best_bar = open_bar(unit) + if best_bar is None: + unplaced.append({**unit, "reason": "stock_exhausted"}) + else: + _place_on_bar(best_bar, unit, kerf) + pending.remove(unit["length_in"]) + return bars, unplaced + + +def _bar_cost(stock): + if stock["cost_per_bar"] is not None: + return stock["cost_per_bar"] + if stock["cost_per_ft"] is not None: + return stock["cost_per_ft"] * stock["length_in"] / 12.0 + return None + + +def _ranking_cost(stock): + """Purchase outlay used for strategy ranking; on-hand material costs nothing.""" + if stock["stock_kind"] == "on_hand": + return 0.0 + return _bar_cost(stock) + + +def _solve_group(units, group_stock, kerf): + """Try a portfolio of deterministic strategies; keep the cheapest result. + + Candidates: the mixed-stock greedy plus each single-stock-length + restriction. Solutions rank by fewest unplaced members, least PURCHASED + stock length (on-hand consumption is free), lowest known purchase cost + (unknown costs rank last), fewest purchased bars, then least total + length. Ties resolve by strategy name for determinism. + """ + strategies = [("mixed", group_stock)] + for stock in group_stock: + strategies.append((f"single:{stock['stock_id']}", [stock])) + best = None + best_rank = None + for name, stocks in strategies: + bars, unplaced = _greedy_pack(units, stocks, kerf, group_stock) + total_length = sum(bar["stock"]["length_in"] for bar in bars) + purchased = [ + bar for bar in bars if bar["stock"]["stock_kind"] == "purchasable" + ] + purchased_length = sum(bar["stock"]["length_in"] for bar in purchased) + costs = [_ranking_cost(bar["stock"]) for bar in bars] + cost_rank = ( + round(sum(costs), 2) if None not in costs else math.inf + ) + rank = ( + sum(1 for _ in unplaced), + round(purchased_length, 6), + cost_rank, + len(purchased), + round(total_length, 6), + name, + ) + if best_rank is None or rank < best_rank: + best_rank = rank + best = (bars, unplaced) + return best if best is not None else ([], []) + + +def run_job(job): + normalized, stock_types, validation_findings = normalize_job(job) + settings = normalized["settings"] + kerf = settings["kerf_in"] + normalized_hash = sha256_bytes(canonical_json_bytes(normalized)) + blockers = [ + finding + for finding in validation_findings + if finding["severity"] == "error" + ] + if blockers: + return _summarize( + normalized, [], [], validation_findings, normalized_hash + ) + + units = [] + for member in normalized["members"]: + member_instances = instance_ids(member["item_id"], member["quantity"]) + member_placements = placement_ids(member["item_id"], member["quantity"]) + for index in range(member["quantity"]): + units.append( + { + **member, + "instance_id": member_instances[index], + "placement_id": member_placements[index], + } + ) + units.sort(key=lambda unit: (-unit["length_in"], unit["instance_id"])) + + units_by_group = defaultdict(list) + for unit in units: + units_by_group[_group_key(unit)].append(unit) + stock_by_group = defaultdict(list) + for stock in stock_types: + stock_by_group[_group_key(stock)].append(stock) + + bars = [] + unplaced_units = [] + for key in sorted(units_by_group, key=repr): + group_bars, group_unplaced = _solve_group( + units_by_group[key], stock_by_group.get(key, []), kerf + ) + bars.extend(group_bars) + unplaced_units.extend(group_unplaced) + + used_bars = [bar for bar in bars if bar["cuts"]] + for index, bar in enumerate(used_bars, 1): + bar["index"] = index + return _summarize( + normalized, + used_bars, + _aggregate_unplaced(unplaced_units), + validation_findings, + normalized_hash, + ) + + +def _aggregate_unplaced(units): + grouped = {} + for unit in units: + key = (unit["item_id"], unit["reason"]) + row = grouped.setdefault( + key, + { + "item_id": unit["item_id"], + "label": unit["label"], + "designation": unit["designation"], + "grade": unit["grade"], + "length_in": unit["length_in"], + "quantity": 0, + "reason": unit["reason"], + }, + ) + row["quantity"] += 1 + return sorted(grouped.values(), key=lambda row: (row["item_id"], row["reason"])) + + +# -------------------------------------------------------------------------- +# Independent verification +# -------------------------------------------------------------------------- +def verify_cutlist_bars(bar_reports, *, kerf, expected_instances): + """Re-check published bars against the declared fit model and coverage.""" + findings = [] + seen_instances = set() + for bar in bar_reports: + cuts = bar["cuts"] + lengths = [cut["length_in"] for cut in cuts] + minimum_consumed = sum(lengths) + kerf * max(len(lengths) - 1, 0) + if minimum_consumed > bar["usable_length_in"] + 1e-6: + findings.append( + _validation_finding( + "BAR_OVERCOMMITTED", + f"$.bar_reports[{bar['index'] - 1}]", + ( + f"Bar {bar['index']} cuts plus kerf exceed its usable " + "length." + ), + ) + ) + for cut_index, cut in enumerate(cuts): + if (cut["designation"], cut["grade"]) != ( + bar["designation"], + bar["grade"], + ): + findings.append( + _validation_finding( + "BAR_MATERIAL_MISMATCH", + f"$.bar_reports[{bar['index'] - 1}].cuts[{cut_index}]", + "Cut designation or grade does not match its bar.", + ) + ) + if cut["instance_id"] in seen_instances: + findings.append( + _validation_finding( + "DUPLICATE_PLACEMENT", + f"$.bar_reports[{bar['index'] - 1}].cuts[{cut_index}]", + f"Instance {cut['instance_id']} is cut more than once.", + ) + ) + seen_instances.add(cut["instance_id"]) + missing = expected_instances - seen_instances + extra = seen_instances - expected_instances + for instance_id in sorted(missing): + findings.append( + _validation_finding( + "INSTANCE_UNACCOUNTED", + "$.bar_reports", + f"Instance {instance_id} is neither cut nor reported unplaced.", + ) + ) + for instance_id in sorted(extra): + findings.append( + _validation_finding( + "INSTANCE_UNEXPECTED", + "$.bar_reports", + f"Instance {instance_id} does not belong to this job.", + ) + ) + return findings + + +# -------------------------------------------------------------------------- +# Summary +# -------------------------------------------------------------------------- +def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_hash): + settings = normalized["settings"] + kerf = settings["kerf_in"] + min_drop = settings["min_drop_in"] + estimate_input_hash = normalized.get("estimate_input_hash") or normalized_hash + + bar_reports = [] + total_stock_length = 0.0 + total_cut_length = 0.0 + total_weight_known = True + total_cut_weight = 0.0 + total_cost = 0.0 + all_costs_known = bool(used_bars) + for bar in used_bars: + stock = bar["stock"] + cuts = [] + cut_length = 0.0 + bar_weight_known = True + bar_cut_weight = 0.0 + for cut_index, unit in enumerate(bar["cuts"], 1): + weight = ( + unit["unit_weight_plf"] * unit["length_in"] / 12.0 + if unit["unit_weight_plf"] is not None + else None + ) + if weight is None: + bar_weight_known = False + else: + bar_cut_weight += weight + cuts.append( + { + "sequence": cut_index, + "item_id": unit["item_id"], + "source_id": unit["source_id"], + "instance_id": unit["instance_id"], + "placement_id": unit["placement_id"], + "label": unit["label"], + "designation": unit["designation"], + "grade": unit["grade"], + "length_in": unit["length_in"], + "weight_lbs": None if weight is None else round(weight, 1), + "weight_basis": unit["weight_basis"], + } + ) + cut_length += unit["length_in"] + kerf_total = kerf * len(cuts) + used_length = min(cut_length + kerf_total, stock["usable_in"]) + drop = max(stock["usable_in"] - used_length, 0.0) + if stock["stock_kind"] == "on_hand": + bar_cost = None + cost_basis = "on_hand" + elif stock["cost_per_bar"] is not None: + bar_cost = stock["cost_per_bar"] + cost_basis = "per_bar" + elif stock["cost_per_ft"] is not None: + bar_cost = stock["cost_per_ft"] * stock["length_in"] / 12.0 + cost_basis = "per_foot" + else: + bar_cost = None + cost_basis = None + all_costs_known = False + if bar_cost is not None: + total_cost += bar_cost + report = { + "index": bar["index"], + "stock": stock["name"], + "stock_id": stock["stock_id"], + "stock_kind": stock["stock_kind"], + "designation": stock["designation"], + "grade": stock["grade"], + "bar_length_in": stock["length_in"], + "usable_length_in": stock["usable_in"], + "end_trim_in": settings["end_trim_in"], + "cuts": cuts, + "num_cuts": len(cuts), + "cut_length_in": round(cut_length, 3), + "kerf_total_in": round(kerf_total, 3), + "drop_in": round(drop, 3), + "drop_class": ( + "reusable_candidate" if drop >= min_drop - EPS else "offcut" + ), + "utilization_pct": round( + 100 * cut_length / stock["length_in"], 1 + ) + if stock["length_in"] + else 0.0, + "cut_weight_lbs": ( + round(bar_cut_weight, 1) if bar_weight_known else None + ), + "bar_cost": None if bar_cost is None else round(bar_cost, 2), + "cost_basis": cost_basis, + } + bar_reports.append(report) + total_stock_length += stock["length_in"] + total_cut_length += cut_length + if bar_weight_known: + total_cut_weight += bar_cut_weight + else: + total_weight_known = False + + expected_instances = set() + for member in normalized["members"]: + expected_instances.update( + instance_ids(member["item_id"], member["quantity"]) + ) + placed_elsewhere = { + cut["instance_id"] + for report in bar_reports + for cut in report["cuts"] + } + for row in unplaced: + prefix = f"{row['item_id']}:instance:" + matching = sorted( + instance + for instance in expected_instances + if instance.startswith(prefix) + ) + removable = [ + instance for instance in matching if instance not in placed_elsewhere + ][-row["quantity"]:] + expected_instances -= set(removable) + + verification_ran = not any( + finding["severity"] == "error" for finding in validation_findings + ) + verification_findings = ( + verify_cutlist_bars( + bar_reports, kerf=kerf, expected_instances=expected_instances + ) + if verification_ran + else [] + ) + + drops = [ + { + "bar_index": report["index"], + "stock_id": report["stock_id"], + "designation": report["designation"], + "grade": report["grade"], + "length_in": report["drop_in"], + "status": "candidate_unverified", + } + for report in bar_reports + if report["drop_class"] == "reusable_candidate" and report["drop_in"] > EPS + ] + drops.sort(key=lambda drop: (-drop["length_in"], drop["bar_index"])) + + purchase_rows = defaultdict( + lambda: {"bars": 0, "length_in": 0.0, "cost_known": True, "cost": 0.0} + ) + for report in bar_reports: + row = purchase_rows[report["stock_id"]] + row["bars"] += 1 + row["length_in"] += report["bar_length_in"] + row["stock_name"] = report["stock"] + row["stock_kind"] = report["stock_kind"] + row["designation"] = report["designation"] + row["grade"] = report["grade"] + row["bar_length_in"] = report["bar_length_in"] + if report["bar_cost"] is None: + row["cost_known"] = False + else: + row["cost"] += report["bar_cost"] + purchase_summary = [ + { + "stock_id": stock_id, + "stock_name": row["stock_name"], + "stock_kind": row["stock_kind"], + "designation": row["designation"], + "grade": row["grade"], + "bar_length_in": row["bar_length_in"], + "bars_needed": row["bars"], + "total_length_ft": round(row["length_in"] / 12.0, 2), + "total_cost": round(row["cost"], 2) if row["cost_known"] else None, + } + for stock_id, row in sorted(purchase_rows.items()) + ] + + if unplaced: + cost_status, cost_total = "incomplete_unplaced", None + elif all_costs_known: + cost_status, cost_total = "known", round(total_cost, 2) + else: + cost_status, cost_total = "not_provided", None + + metrics = { + "utilization_pct": { + "value": round( + 100 * total_cut_length / total_stock_length + if total_stock_length + else 0, + 1, + ), + "approximation": "exact", + }, + "total_kerf_in": round( + sum(report["kerf_total_in"] for report in bar_reports), 3 + ), + } + configuration_hash = sha256_bytes( + canonical_json_bytes( + { + "algorithm_version": CUTLIST_ALGORITHM_VERSION, + "settings": settings, + "fit_contract": "length-plus-kerf-saturating-v1", + } + ) + ) + result = { + "schema_version": CUTLIST_RESULT_VERSION, + "algorithm_version": CUTLIST_ALGORITHM_VERSION, + "normalized_input_hash": normalized_hash, + "estimate_input_hash": estimate_input_hash, + "configuration_hash": configuration_hash, + "outcome": "blocked", + "package_status": "draft", + "meta": { + "job_name": normalized["job_name"], + "customer": normalized["customer"], + "project_id": normalized["project_id"], + "revision_id": normalized["revision_id"], + "kerf_in": kerf, + "end_trim_in": settings["end_trim_in"], + "min_drop_in": min_drop, + "unit_system": normalized["unit_system"], + }, + "fit_contract": { + "usable_length": "bar_length_minus_two_end_trims", + "piece_fits_when": "length_within_remaining", + "consumption_per_piece": "length_plus_one_kerf_saturating", + }, + "bars_used": len(bar_reports), + "metrics": metrics, + "total_stock_length_ft": round(total_stock_length / 12.0, 2), + "total_cut_length_ft": round(total_cut_length / 12.0, 2), + "total_cut_weight_lbs": ( + round(total_cut_weight, 1) if total_weight_known and bar_reports else None + ), + "weight_status": "known" if total_weight_known else "incomplete", + "cost": {"status": cost_status, "total": cost_total}, + "total_material_cost": cost_total, + "cost_known": cost_status == "known", + "bar_reports": bar_reports, + "purchase_summary": purchase_summary, + "drops": drops, + "unplaced": unplaced, + "validation_findings": validation_findings, + "verification": { + "status": ( + "not_run" + if not verification_ran + else ("verified" if not verification_findings else "failed") + ), + "findings": verification_findings, + }, + } + result["rfq_linear"] = rfq_linear_block(result) + outcome, package_status, _ = stage_decision(result) + result["outcome"] = outcome + result["package_status"] = package_status + return result + + +def stage_decision(res): + """Map the independently verified result onto the shared stage contract.""" + findings = list(res.get("validation_findings", [])) + findings.extend( + {**finding, "severity": "error"} + for finding in res.get("verification", {}).get("findings", []) + ) + if res["unplaced"]: + findings.append( + { + "code": "UNPLACED_MEMBERS", + "severity": "error", + "path": "$.unplaced", + "message": ( + f"{sum(row['quantity'] for row in res['unplaced'])} " + "required member(s) remain unplaced." + ), + } + ) + if any(finding["severity"] == "error" for finding in findings): + outcome = "blocked" + package_status = "cutlist_partial" if res["unplaced"] else "draft" + else: + outcome = "ready" + package_status = "cutlist_verified" + return outcome, package_status, findings + + +def _fmt(value): + """Decimal-exact display formatting; never rounds shop-relevant digits.""" + text = f"{value:.6f}".rstrip("0").rstrip(".") + return text or "0" + + +# -------------------------------------------------------------------------- +# RFQ hand-off block (feeds steel-rfq linear stock rows) +# -------------------------------------------------------------------------- +def rfq_linear_block(res): + """Build the versioned cut-list-to-RFQ handoff grouped by stock.""" + rows = [] + for purchase in res["purchase_summary"]: + matching = [ + report + for report in res["bar_reports"] + if report["stock_id"] == purchase["stock_id"] + ] + cuts = sum(report["num_cuts"] for report in matching) + group_bar_length = sum(report["bar_length_in"] for report in matching) + group_cut_length = sum(report["cut_length_in"] for report in matching) + group_utilization = ( + round(100 * group_cut_length / group_bar_length, 1) + if group_bar_length + else 0.0 + ) + drop_candidates = [ + drop + for drop in res["drops"] + if drop["stock_id"] == purchase["stock_id"] + ][:3] + drop_text = ( + "; ".join(_fmt(drop["length_in"]) for drop in drop_candidates) or "none" + ) + rows.append( + { + "stock_id": purchase["stock_id"], + "stock_name": purchase["stock_name"], + "stock_kind": purchase["stock_kind"], + "designation": purchase["designation"], + "grade": purchase["grade"], + "bar_length_in": purchase["bar_length_in"], + "bars_needed": purchase["bars_needed"], + "total_length_ft": purchase["total_length_ft"], + "utilization_pct": group_utilization, + "cutting_plan": ( + f"{purchase['bars_needed']} x " + f"{_fmt(purchase['bar_length_in'])} in bar(s) - {cuts} cuts" + ), + "drop_notes": ( + "Drop candidates (not certified reusable): " + f"{drop_text} in" + ), + "drop_candidates": drop_candidates, + "total_cost": purchase["total_cost"] if not res["unplaced"] else None, + } + ) + return { + "schema_version": "1.0.0", + "source_cutlist_result_version": CUTLIST_RESULT_VERSION, + "project_id": res["meta"]["project_id"], + "revision_id": res["meta"]["revision_id"], + "estimate_input_hash": res["estimate_input_hash"], + "rows": rows, + } + + +# -------------------------------------------------------------------------- +# Text report +# -------------------------------------------------------------------------- +def render_text(res): + meta = res["meta"] + lines = ["=" * 64, f" CUT LIST — {meta['job_name']}"] + if meta.get("customer"): + lines.append(f" Customer: {meta['customer']}") + lines.append("=" * 64) + lines.append( + f" Kerf {meta['kerf_in']}\" | End trim {meta['end_trim_in']}\"/end | " + f"Reusable drop >= {meta['min_drop_in']}\"" + ) + lines.append("") + lines.append(f" Bars used .............. {res['bars_used']}") + utilization = res["metrics"]["utilization_pct"] + lines.append(f" Length utilization ..... {utilization['value']}%") + lines.append(f" Stock length ........... {res['total_stock_length_ft']} ft") + lines.append(f" Cut length ............. {res['total_cut_length_ft']} ft") + if res["total_cut_weight_lbs"] is not None: + lines.append(f" Cut weight ............. {res['total_cut_weight_lbs']} lb") + else: + lines.append(" Cut weight ............. incomplete (missing unit weights)") + if res["cost_known"]: + lines.append(f" Material cost (bars) ... ${res['total_material_cost']:,.2f}") + else: + lines.append(" Material cost .......... (add cost_per_ft or cost_per_bar to stock)") + lines.append("") + lines.append(" " + "-" * 60) + for report in res["bar_reports"]: + lines.append( + f" BAR {report['index']} — {report['stock']} " + f"({_fmt(report['bar_length_in'])} in {report['designation']} {report['grade']})" + ) + for cut in report["cuts"]: + lines.append( + f" {cut['sequence']:>2}. {cut['label']:<22} {_fmt(cut['length_in'])} in" + ) + lines.append( + f" Cuts {report['num_cuts']} | Kerf {report['kerf_total_in']} in | " + f"Drop {report['drop_in']} in ({report['drop_class']}) | " + f"Utilization {report['utilization_pct']}%" + ) + if report["bar_cost"] is not None: + lines.append(f" Cost: ${report['bar_cost']:,.2f}") + lines.append("") + if res["purchase_summary"]: + lines.append(" PURCHASE SUMMARY:") + for row in res["purchase_summary"]: + cost_text = ( + f" ${row['total_cost']:,.2f}" if row["total_cost"] is not None else "" + ) + on_hand_text = " (on hand)" if row["stock_kind"] == "on_hand" else "" + lines.append( + f" {row['bars_needed']} x {_fmt(row['bar_length_in'])} in " + f"{row['designation']} {row['grade']} " + f"({row['total_length_ft']} ft){cost_text}{on_hand_text}" + ) + lines.append("") + if res["drops"]: + lines.append(" DROP CANDIDATES (not certified reusable):") + for drop in res["drops"]: + lines.append( + f" Bar {drop['bar_index']}: {_fmt(drop['length_in'])} in " + f"{drop['designation']} {drop['grade']}" + ) + lines.append("") + if res["unplaced"]: + lines.append(" " + "!" * 60) + lines.append(" DID NOT FIT (need more/longer stock):") + for row in res["unplaced"]: + lines.append( + f" - {row['label']} x{row['quantity']} " + f"({_fmt(row['length_in'])} in {row['designation']}; {row['reason']})" + ) + lines.append("") + lines.append("=" * 64) + return "\n".join(lines) + + +# -------------------------------------------------------------------------- +# Cutting list CSV (only for verified, fully placed runs) +# -------------------------------------------------------------------------- +def render_cutting_list_csv(res): + buffer = io.StringIO() + writer = csv.writer(buffer, lineterminator="\n") + writer.writerow( + [ + "Bar", + "Stock", + "Designation", + "Grade", + "Bar_Length_in", + "Seq", + "Mark", + "Cut_Length_in", + "Item_ID", + "Instance_ID", + ] + ) + for report in res["bar_reports"]: + for cut in report["cuts"]: + writer.writerow( + [ + report["index"], + report["stock"], + report["designation"], + report["grade"], + _fmt(report["bar_length_in"]), + cut["sequence"], + cut["label"], + _fmt(cut["length_in"]), + cut["item_id"], + cut["instance_id"], + ] + ) + return buffer.getvalue() + + +# -------------------------------------------------------------------------- +# Visual layout (PNG + combined PDF) +# -------------------------------------------------------------------------- +def render_layout(res, outdir, pdf_name="layout.pdf", png_name="bars.png"): + import matplotlib + + matplotlib.use("Agg") + import matplotlib.patches as mpatches + import matplotlib.pyplot as plt + from matplotlib.backends.backend_pdf import PdfPages + + end_trim = res["meta"]["end_trim_in"] + kerf = res["meta"]["kerf_in"] + pdf_path = os.path.join(outdir, pdf_name) + png_path = os.path.join(outdir, png_name) + reports = res["bar_reports"] + + with PdfPages(pdf_path) as pdf: + fig_height = max(2.5, 0.6 * len(reports) + 1.5) + fig, ax = plt.subplots(figsize=(11, fig_height)) + max_length = max( + (report["bar_length_in"] for report in reports), default=1.0 + ) + for row, report in enumerate(reports): + y = len(reports) - row - 1 + bar_length = report["bar_length_in"] + ax.add_patch( + mpatches.Rectangle( + (0, y + 0.1), bar_length, 0.8, fill=False, lw=1.5, ec="#222" + ) + ) + cursor = end_trim + for cut in report["cuts"]: + ax.add_patch( + mpatches.Rectangle( + (cursor, y + 0.1), + cut["length_in"], + 0.8, + facecolor="#a9c8e8", + edgecolor="#1a3b5c", + lw=1.0, + alpha=0.9, + ) + ) + ax.text( + cursor + cut["length_in"] / 2, + y + 0.5, + f"{cut['label']}\n{_fmt(cut['length_in'])}\"", + ha="center", + va="center", + fontsize=6, + color="#0c2233", + ) + cursor += cut["length_in"] + kerf + if report["drop_in"] > EPS: + drop_face = ( + "#bde6bd" + if report["drop_class"] == "reusable_candidate" + else "#e8e8e8" + ) + ax.add_patch( + mpatches.Rectangle( + (bar_length - end_trim - report["drop_in"], y + 0.1), + report["drop_in"], + 0.8, + facecolor=drop_face, + edgecolor="#666", + lw=0.5, + alpha=0.7, + ) + ) + ax.text( + -0.01 * max_length, + y + 0.5, + f"BAR {report['index']}", + ha="right", + va="center", + fontsize=8, + fontweight="bold", + ) + ax.set_xlim(-0.12 * max_length, max_length * 1.02) + ax.set_ylim(-0.2, len(reports) + 0.2) + ax.set_yticks([]) + ax.set_xlabel("inches") + ax.set_title( + f"CUT LIST — {res['meta']['job_name']} " + f"({res['bars_used']} bars, " + f"{res['metrics']['utilization_pct']['value']}% utilization)", + fontsize=12, + fontweight="bold", + ) + ax.grid(True, axis="x", lw=0.3, color="#eee") + fig.savefig(png_path, dpi=110, bbox_inches="tight") + pdf.savefig(fig, bbox_inches="tight") + plt.close(fig) + + fig = plt.figure(figsize=(11, 8.5)) + fig.text(0.5, 0.94, "CUT LIST SUMMARY", ha="center", fontsize=18, fontweight="bold") + fig.text( + 0.06, 0.88, render_text(res), family="monospace", fontsize=7.0, va="top" + ) + pdf.savefig(fig) + plt.close(fig) + + return pdf_path, png_path + + +def missing_render_dependencies(): + """Return optional render modules unavailable to this interpreter.""" + return missing_optional_modules(("matplotlib", "numpy")) + + +# -------------------------------------------------------------------------- +# Publication +# -------------------------------------------------------------------------- +def publish_cutlist_run(job, args): + """Run a cut-list job and publish one isolated, manifested artifact set.""" + result = run_job(job) + missing_dependencies = [] if args.no_render else missing_render_dependencies() + if missing_dependencies: + outcome = "dependency_missing" + package_status = "draft" + findings = [ + { + "code": "RENDER_DEPENDENCY_MISSING", + "severity": "error", + "message": ( + "Rendering requires the missing module(s): " + + ", ".join(missing_dependencies) + ), + } + ] + else: + outcome, package_status, findings = stage_decision(result) + result["outcome"] = outcome + result["run_outcome"] = outcome + result["package_status"] = package_status + report = render_text(result) + + configuration = { + "algorithm_version": CUTLIST_ALGORITHM_VERSION, + "engine_configuration_hash": result["configuration_hash"], + "render": not args.no_render, + } + publication_configuration_hash = sha256_bytes(canonical_json_bytes(configuration)) + qa_report = { + "schema_version": "1.0.0", + "stage": "steel-cutlist", + "run_outcome": outcome, + "package_status": package_status, + "findings": findings, + } + with RunPublisher( + args.out, + stage="steel-cutlist", + run_outcome=outcome, + package_status=package_status, + input_hash=result["normalized_input_hash"], + configuration_hash=publication_configuration_hash, + schema_versions={ + "run_manifest": "1.0.0", + "cutlist_result": CUTLIST_RESULT_VERSION, + "rfq_linear": "1.0.0", + }, + tool_versions={ + "pi_steel": package_version(__file__), + "cutlist_algorithm": CUTLIST_ALGORITHM_VERSION, + }, + explicit_dates={}, + warnings=[ + finding["message"] + for finding in findings + if finding["severity"] in {"error", "warning"} + ], + approximations=[], + run_id=args.run_id, + ) as publisher: + publisher.write_qa_report(qa_report) + publisher.write_bytes( + "report.txt", + report.encode("utf-8"), + readiness="diagnostic", + media_type="text/plain", + ) + publisher.write_json("result.json", result, readiness="diagnostic") + if outcome == "ready": + publisher.write_json( + "rfq_linear.json", result["rfq_linear"], readiness="diagnostic" + ) + publisher.write_bytes( + "cutting_list.csv", + render_cutting_list_csv(result).encode("utf-8"), + readiness="geometry_verified", + media_type="text/csv", + ) + if ( + not args.no_render + and not missing_dependencies + and result["bar_reports"] + ): + publisher.register_artifact( + "layout.pdf", readiness="reference_only", media_type="application/pdf" + ) + publisher.register_artifact( + "bars.png", readiness="reference_only", media_type="image/png" + ) + render_layout(result, publisher.staging_path) + final_path = publisher.publish() + + return result, qa_report, report, final_path + + +# -------------------------------------------------------------------------- +# Main +# -------------------------------------------------------------------------- +def main(argv=None): + parser = StageArgumentParser(description="Steel linear cut-list engine") + parser.configure_failure_diagnostics( + stage="steel-cutlist", + entry_file=__file__, + input_option="--job", + ) + parser.add_argument("--job", required=True) + parser.add_argument( + "--out", + default="outputs", + help="Publication root; each invocation writes an isolated runs//", + ) + parser.add_argument("--no-render", action="store_true", help="Skip PDF/PNG") + parser.add_argument("--run-id", help=argparse.SUPPRESS) + args = parser.parse_args(argv) + + try: + with open(args.job, encoding="utf-8") as handle: + job = json.load(handle) + result, qa_report, report, final_path = publish_cutlist_run(job, args) + except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc: + diagnostic_path = publish_failure_diagnostic( + args.out, + stage="steel-cutlist", + input_path=args.job, + error=exc, + tool_version=package_version(__file__), + run_id=args.run_id, + ) + suffix = ( + f"; diagnostic published: {diagnostic_path}" + if diagnostic_path is not None + else "; diagnostic publication unavailable" + ) + print(f"Cut-list failed: {exc}{suffix}", file=sys.stderr) + return 1 + print(report) + print(f"\nPublished {qa_report['run_outcome']} run: {final_path}") + return outcome_exit_code(qa_report["run_outcome"]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/steel-estimate/SKILL.md b/skills/steel-estimate/SKILL.md index 3701011..4b8d957 100644 --- a/skills/steel-estimate/SKILL.md +++ b/skills/steel-estimate/SKILL.md @@ -1,6 +1,6 @@ --- name: steel-estimate -description: "Build a deterministic, review-gated steel estimate package from canonical estimate JSON. Use when a complete takeoff-to-nest-to-draft-RFQ workflow is needed with validation, lineage, QA, and isolated run artifacts." +description: "Build a deterministic, review-gated steel estimate package from canonical estimate JSON. Use when a complete takeoff-to-nest-to-cutlist-to-draft-RFQ workflow is needed with validation, lineage, QA, and isolated run artifacts." --- # Steel Estimate Package @@ -18,6 +18,10 @@ authorize a purchase. The workbook remains `DRAFT — NOT SENT OR AWARDED`. - A runtime company profile accepted by `steel-rfq`. - Plate stock compatible with every plate part by material, grade, thickness, and usable dimensions. +- Optional linear stock (`stock_form: "linear"`): purchasable vendor lengths + (which replace the default mill lengths for their designation + grade + group) and on-hand sticks, which require the same hash-bound reviewer + confirmation as on-hand plate before they can reduce purchasing. Use `references/estimate-package-example.json` as a synthetic input example. The company profile resolution and approval rules are documented by @@ -33,13 +37,20 @@ calculations. 2. Build the typed BOM projection without converting exclusions or allowances into vendor quantities. 3. Nest plate parts only within compatible stock groups. -4. Verify placements and retain diagnostic nest artifacts. -5. Compile a draft RFQ only when validation, placement, and company-profile - gates pass. -6. Publish an isolated run manifest and QA report. - -Validation failures, unplaced parts, invalid company data, failed nest -verification, or required rendering dependencies block workbook generation. +4. Optimize member items (designation plus length, no plate geometry) onto + configurable mill lengths (`--mill-lengths-ft`, default `40,50,60`) with + the deterministic cut-list engine; settings: `--cutlist-kerf-in`, + `--end-trim-in`, `--min-drop-in`. +5. Verify placements and cut plans; retain diagnostic nest and cut-list + artifacts (`cutlist-result.json`, `rfq-linear.json`, and a verified + `cutting_list.csv` on ready runs). +6. Compile a draft RFQ — including the linear stock reference table — only + when validation, placement, and company-profile gates pass. +7. Publish an isolated run manifest and QA report. + +Validation failures, unplaced parts, members longer than every configured mill +length, invalid company data, failed nest or cut-list verification, or +required rendering dependencies block workbook generation. Complete bounding-box nests for irregular geometry may produce a workbook, but the run and workbook remain explicitly review-required. Reference DXF is never burn-ready DXF. diff --git a/skills/steel-estimate/scripts/build-estimate-package.py b/skills/steel-estimate/scripts/build-estimate-package.py index 1ecddf5..13464fc 100755 --- a/skills/steel-estimate/scripts/build-estimate-package.py +++ b/skills/steel-estimate/scripts/build-estimate-package.py @@ -7,6 +7,7 @@ import copy import importlib.util import json +import math import os import re import sys @@ -34,7 +35,9 @@ sha256_bytes, ) from pi_steel.contracts import ESTIMATE_PACKAGE_VERSION # noqa: E402 +from pi_steel.parsing import normalize_designation # noqa: E402 from pi_steel.validation import ( # noqa: E402 + eligible_on_hand_linear_stock, eligible_on_hand_stock, validate_estimate_package, ) @@ -60,6 +63,10 @@ def _load_module(name: str, path: Path): "pi_steel_estimate_rfq", SKILLS_ROOT / "steel-rfq" / "scripts" / "generate-rfq.py", ) +cutlist_engine = _load_module( + "pi_steel_estimate_cutlist", + SKILLS_ROOT / "steel-cutlist" / "scripts" / "cutlist.py", +) class PipelineInputError(ValueError): @@ -137,6 +144,8 @@ def nest_job_from_package( return None stock_rows = [] for index, stock in enumerate(package.get("stock", []), start=1): + if stock.get("stock_form") == "linear": + continue if ( stock["stock_kind"] == "on_hand" and stock.get("inventory_id") not in eligible_inventory_ids @@ -193,6 +202,141 @@ def nest_job_from_package( } +def parse_mill_lengths(raw: str) -> list[float]: + """Parse the comma-separated mill length list (feet), strictly.""" + lengths = [] + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + try: + value = float(entry) + except ValueError as exc: + raise PipelineInputError( + f"--mill-lengths-ft entry {entry!r} is not a number" + ) from exc + if not math.isfinite(value) or value <= 0: + raise PipelineInputError( + "--mill-lengths-ft entries must be finite and greater than zero" + ) + lengths.append(value) + if not lengths: + raise PipelineInputError("--mill-lengths-ft must list at least one length") + return sorted(set(lengths)) + + +def cutlist_job_from_package( + package: dict[str, Any], + *, + estimate_input_hash: str, + mill_lengths_ft: list[float], + kerf_in: float, + end_trim_in: float, + min_drop_in: float, + eligible_linear_inventory_ids: set[str] = frozenset(), +) -> dict[str, Any] | None: + """Build the linear optimization job for member items purchased by length. + + Declared purchasable linear stock replaces the default mill lengths for + its designation + grade group; confirmed on-hand sticks are added as + finite-quantity entries. + """ + member_items = [ + item + for item in package["items"] + if item["intent"] == "fabricated_part" + and not item.get("geometry") + and item.get("designation") + and item.get("length_ft") is not None + and item.get("grade") + ] + if not member_items: + return None + members = [] + for item in member_items: + member = { + "source_id": item["source_id"], + "item_id": item["item_id"], + "name": item.get("mark") or item["item_id"], + "designation": item["designation"], + "grade": item.get("grade"), + "length_ft": item["length_ft"], + "qty": item["quantity"], + } + if item.get("unit_weight_plf") is not None: + member["unit_weight_plf"] = item["unit_weight_plf"] + members.append(member) + groups = sorted( + { + (member["designation"], member["grade"]) + for member in members + }, + key=repr, + ) + stock = [] + declared_purchasable_groups = set() + for index, entry in enumerate(package.get("stock", []), start=1): + if entry.get("stock_form") != "linear": + continue + if ( + entry["stock_kind"] == "on_hand" + and entry.get("inventory_id") not in eligible_linear_inventory_ids + ): + continue + row = { + "stock_id": entry.get("inventory_id") or f"vendor-linear:{index:04d}", + "name": entry.get("inventory_id") + or ( + f"{entry['designation']} {entry['length_ft']:g} ft " + "vendor length" + ), + "stock_kind": entry["stock_kind"], + "designation": entry["designation"], + "grade": entry["grade"], + "length_ft": entry["length_ft"], + "qty": entry["quantity"], + } + if entry.get("unlimited") and entry["stock_kind"] == "purchasable": + row["unlimited"] = True + stock.append(row) + if entry["stock_kind"] == "purchasable": + declared_purchasable_groups.add( + (normalize_designation(entry["designation"]), entry["grade"]) + ) + for designation, grade in groups: + if ( + normalize_designation(designation), + grade, + ) in declared_purchasable_groups: + continue + stock.extend( + { + "stock_id": f"mill:{designation}:{grade}:{length_ft:g}ft", + "name": f"{designation} {length_ft:g} ft mill length", + "designation": designation, + "grade": grade, + "length_ft": length_ft, + "unlimited": True, + } + for length_ft in mill_lengths_ft + ) + return { + "job_name": package["project"].get("name") + or package["project"]["project_id"], + "project_id": package["project"]["project_id"], + "revision_id": package["project"]["revision"]["revision_id"], + "estimate_input_hash": estimate_input_hash, + "unit_system": package["unit_system"], + "settings": { + "kerf_in": kerf_in, + "end_trim_in": end_trim_in, + "min_drop_in": min_drop_in, + }, + "members": members, + "stock": stock, + } + + def build_bom_projection( package: dict[str, Any], nest_result: dict[str, Any] | None ) -> dict[str, Any]: @@ -420,6 +564,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: raise PipelineInputError("--prepared-date must be an ISO date (YYYY-MM-DD)") if not _valid_date(args.issued_date): raise PipelineInputError("--issued-date must be an ISO date (YYYY-MM-DD)") + mill_lengths = parse_mill_lengths(args.mill_lengths_ft) input_path = Path(args.input) package = json.loads(input_path.read_text(encoding="utf-8")) validation = validate_estimate_package(package) @@ -436,6 +581,14 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: if not validation.blockers else set() ) + eligible_linear_inventory_ids = ( + { + stock["inventory_id"] + for stock in eligible_on_hand_linear_stock(package) + } + if not validation.blockers + else set() + ) findings = [ { "code": finding["code"], @@ -517,7 +670,74 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: ), ) ) - unplaced = bool(nest_result and nest_result["unplaced"]) + cutlist_result = None + if not validation_blocked: + for item in normalized["items"]: + if ( + item["intent"] == "fabricated_part" + and not item.get("geometry") + and item.get("designation") + and item.get("length_ft") is not None + and not item.get("grade") + ): + findings.append( + _finding( + "member_missing_grade_excluded_from_cutlist", + "warning", + "$.items", + ( + f"Member {item['item_id']} has no grade and was " + "excluded from cut-list optimization." + ), + ) + ) + cutlist_job = cutlist_job_from_package( + normalized, + estimate_input_hash=validation.input_hash, + mill_lengths_ft=mill_lengths, + kerf_in=args.cutlist_kerf_in, + end_trim_in=args.end_trim_in, + min_drop_in=args.min_drop_in, + eligible_linear_inventory_ids=eligible_linear_inventory_ids, + ) + if cutlist_job is not None: + cutlist_result = cutlist_engine.run_job(cutlist_job) + for cutlist_finding in cutlist_result["validation_findings"]: + findings.append( + _finding( + cutlist_finding["code"], + "blocker" + if cutlist_finding["severity"] == "error" + else "warning", + cutlist_finding["path"], + cutlist_finding["message"], + ) + ) + for verifier_finding in cutlist_result["verification"]["findings"]: + findings.append( + _finding( + verifier_finding["code"], + "blocker", + verifier_finding["path"], + verifier_finding["message"], + ) + ) + if cutlist_result["unplaced"]: + findings.append( + _finding( + "unplaced_members", + "blocker", + "$.cutlist.unplaced", + ( + f"{sum(row['quantity'] for row in cutlist_result['unplaced'])} " + "required member(s) exceed every configured mill length." + ), + ) + ) + unplaced = bool( + (nest_result and nest_result["unplaced"]) + or (cutlist_result and cutlist_result["unplaced"]) + ) nest_blocked = bool( nest_result and ( @@ -525,6 +745,13 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: or nest_result["verification"]["status"] != "verified" ) ) + cutlist_blocked = bool( + cutlist_result + and ( + cutlist_result["outcome"] == "blocked" + or cutlist_result["verification"]["status"] != "verified" + ) + ) reference_only = bool( nest_result and nest_result["geometry_readiness"] == "reference_only" ) @@ -538,8 +765,14 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: ) render_missing = [] - if not args.no_render and nest_result is not None: - render_missing = nest_engine.missing_render_dependencies() + if not args.no_render and ( + nest_result is not None or cutlist_result is not None + ): + render_missing = ( + nest_engine.missing_render_dependencies() + if nest_result is not None + else cutlist_engine.missing_render_dependencies() + ) if render_missing: findings.append( _finding( @@ -553,7 +786,13 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: rfq_normalized = None inventory_consumption = [] compiler_error = None - if not validation_blocked and not profile_blocked and not nest_blocked and not render_missing: + if ( + not validation_blocked + and not profile_blocked + and not nest_blocked + and not cutlist_blocked + and not render_missing + ): try: rfq_normalized = rfq_compiler.normalize_canonical_package(package) inventory_consumption = apply_inventory_consumption( @@ -576,6 +815,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: validation_blocked or profile_blocked or nest_blocked + or cutlist_blocked or unplaced or bool(render_missing) or compiler_error is not None @@ -587,7 +827,12 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: ] if blocked: outcome = "dependency_missing" if render_missing else "blocked" - package_status = "nested_partial" if unplaced else "draft" + if nest_result and nest_result["unplaced"]: + package_status = "nested_partial" + elif cutlist_result and cutlist_result["unplaced"]: + package_status = "cutlist_partial" + else: + package_status = "draft" elif reference_only or review_warnings: outcome = "review_required" package_status = "rfq_draft_review_required" @@ -596,9 +841,15 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: package_status = "rfq_ready_for_review" handoff = _annotated_handoff(nest_result) + linear_handoff = ( + copy.deepcopy(cutlist_result["rfq_linear"]) + if cutlist_result is not None + else None + ) configuration = { "pipeline_version": PIPELINE_VERSION, "nest_algorithm_version": nest_engine.NEST_ALGORITHM_VERSION, + "cutlist_algorithm_version": cutlist_engine.CUTLIST_ALGORITHM_VERSION, "rfq_compiler_version": rfq_compiler.RFQ_COMPILER_VERSION, "prepared_date": args.prepared_date, "issued_date": args.issued_date, @@ -607,6 +858,10 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: "part_gap_in": args.part_gap_in, "edge_margin_in": args.edge_margin_in, "density_lb_in3": args.density_lb_in3, + "mill_lengths_ft": mill_lengths, + "cutlist_kerf_in": args.cutlist_kerf_in, + "end_trim_in": args.end_trim_in, + "min_drop_in": args.min_drop_in, "render": not args.no_render, "bake": not args.no_bake, "profile_hash": rfq_compiler.profile_semantic_hash(profile), @@ -657,6 +912,17 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: "verification": nest_result["verification"], } ), + "cutlist": ( + None + if cutlist_result is None + else { + "outcome": cutlist_result["outcome"], + "unplaced": cutlist_result["unplaced"], + "verification": cutlist_result["verification"], + "weight_status": cutlist_result["weight_status"], + "purchase_summary": cutlist_result["purchase_summary"], + } + ), "rfq": { "generated": not blocked, "document_status": "DRAFT — NOT SENT OR AWARDED", @@ -676,13 +942,16 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: "estimate_package": ESTIMATE_PACKAGE_VERSION, "normalized_bom": "1.0.0", "nest_result": nest_engine.NEST_RESULT_VERSION, + "cutlist_result": cutlist_engine.CUTLIST_RESULT_VERSION, "rfq_nesting": rfq_compiler.NEST_HANDOFF_VERSION, + "rfq_linear": rfq_compiler.LINEAR_HANDOFF_VERSION, "rfq_workbook": rfq_compiler.RFQ_COMPILER_VERSION, }, tool_versions={ "pi_steel": package_version(__file__), "estimate_pipeline": PIPELINE_VERSION, "nest_algorithm": nest_engine.NEST_ALGORITHM_VERSION, + "cutlist_algorithm": cutlist_engine.CUTLIST_ALGORITHM_VERSION, "rfq_compiler": rfq_compiler.RFQ_COMPILER_VERSION, }, explicit_dates={ @@ -704,6 +973,22 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: publisher.write_json( "rfq-nesting.json", handoff, readiness="diagnostic" ) + if cutlist_result is not None: + publisher.write_json( + "cutlist-result.json", cutlist_result, readiness="diagnostic" + ) + publisher.write_json( + "rfq-linear.json", linear_handoff, readiness="diagnostic" + ) + if cutlist_result["outcome"] == "ready" and outcome == "ready": + publisher.write_bytes( + "cutting_list.csv", + cutlist_engine.render_cutting_list_csv( + cutlist_result + ).encode("utf-8"), + readiness="geometry_verified", + media_type="text/csv", + ) if inventory_consumption: publisher.write_json( "inventory-consumption.json", @@ -716,6 +1001,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: rfq_normalized, profile, nest_handoff=handoff, + linear_handoff=linear_handoff, issued_date=args.issued_date, project_location=args.project_location, output_directory=publisher.staging_path, @@ -739,6 +1025,29 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: readiness="diagnostic", ) + if ( + not args.no_render + and cutlist_result is not None + and not render_missing + and cutlist_result["bar_reports"] + ): + cutlist_engine.render_layout( + cutlist_result, + publisher.staging_path, + pdf_name="cutlist-layout.pdf", + png_name="cutlist-bars.png", + ) + publisher.register_artifact( + "cutlist-layout.pdf", + readiness="reference_only", + media_type="application/pdf", + ) + publisher.register_artifact( + "cutlist-bars.png", + readiness="reference_only", + media_type="image/png", + ) + if not args.no_render and nest_result is not None and not render_missing: nest_engine.render_layout(nest_result, publisher.staging_path) publisher.register_artifact( @@ -802,6 +1111,14 @@ def main(argv=None) -> int: parser.add_argument("--part-gap-in", type=float, default=0.25) parser.add_argument("--edge-margin-in", type=float, default=0.5) parser.add_argument("--density-lb-in3", type=float, default=0.2836) + parser.add_argument( + "--mill-lengths-ft", + default="40,50,60", + help="Comma-separated purchasable mill lengths for member cut-lists", + ) + parser.add_argument("--cutlist-kerf-in", type=float, default=0.125) + parser.add_argument("--end-trim-in", type=float, default=0.25) + parser.add_argument("--min-drop-in", type=float, default=24.0) parser.add_argument("--no-render", action="store_true") parser.add_argument("--no-bake", action="store_true") parser.add_argument("--run-id", help=argparse.SUPPRESS) diff --git a/skills/steel-nest/SKILL.md b/skills/steel-nest/SKILL.md index 2546800..5f1c692 100644 --- a/skills/steel-nest/SKILL.md +++ b/skills/steel-nest/SKILL.md @@ -1,6 +1,6 @@ --- name: steel-nest -description: "Nest steel parts onto compatible stock plates and estimate material — the plate-layout / cutting step that CAM software does. Use this skill whenever someone mentions nesting, plate layout, plate optimization, cut list, cutting plan, yield, remnant candidates, how many sheets/plates a job needs, how much plate to buy, or laying parts out on a sheet. Produces a verified result, packing utilization, net material yield, guarded layouts, and optional cost totals only when an explicit basis exists." +description: "Nest steel parts onto compatible stock plates and estimate material — the plate-layout / cutting step that CAM software does. Use this skill whenever someone mentions nesting, plate layout, plate optimization, sheet cut plans, yield, remnant candidates, how many sheets/plates a job needs, how much plate to buy, or laying parts out on a sheet. For long products bought by length (beams, HSS, angle, pipe) use steel-cutlist instead. Produces a verified result, packing utilization, net material yield, guarded layouts, and optional cost totals only when an explicit basis exists." --- # Steel Plate Nesting & Estimate diff --git a/skills/steel-nest/scripts/nest.py b/skills/steel-nest/scripts/nest.py index 826ac31..dbf9190 100644 --- a/skills/steel-nest/scripts/nest.py +++ b/skills/steel-nest/scripts/nest.py @@ -36,7 +36,6 @@ """ import argparse -import importlib.util import json import math import os @@ -57,7 +56,9 @@ RunPublisher, StageArgumentParser, canonical_json_bytes, + is_sha256, item_id_for, + missing_optional_modules, outcome_exit_code, package_version, placement_ids, @@ -76,12 +77,7 @@ NEST_ALGORITHM_VERSION = "maxrects-bssf-u3" -def _valid_hash(value): - return ( - isinstance(value, str) - and len(value) == 64 - and all(character in "0123456789abcdef" for character in value) - ) +_valid_hash = is_sha256 # -------------------------------------------------------------------------- @@ -1491,8 +1487,7 @@ def stage_decision(res, geometry_verified_only=False): def missing_render_dependencies(): """Return optional render modules unavailable to this interpreter.""" - modules = ("ezdxf", "matplotlib", "numpy") - return [name for name in modules if importlib.util.find_spec(name) is None] + return missing_optional_modules(("ezdxf", "matplotlib", "numpy")) def publish_nest_run(job, args): diff --git a/skills/steel-rfq/SKILL.md b/skills/steel-rfq/SKILL.md index b8d78af..8cabce8 100644 --- a/skills/steel-rfq/SKILL.md +++ b/skills/steel-rfq/SKILL.md @@ -1,6 +1,6 @@ --- name: steel-rfq -description: "Compile a deterministic draft steel RFQ workbook from a validated canonical estimate package or an exact-header legacy workbook. Use for material quote lists, RFQ spreadsheets, or versioned nesting references. Produces artifacts only; it never sends, awards, or authorizes purchasing." +description: "Compile a deterministic draft steel RFQ workbook from a validated canonical estimate package or an exact-header legacy workbook. Use for material quote lists, RFQ spreadsheets, or versioned nesting and cut-list references. Produces artifacts only; it never sends, awards, or authorizes purchasing." --- # Steel RFQ Compiler @@ -19,6 +19,7 @@ purchase. Every workbook and manifest records `DRAFT — NOT SENT OR AWARDED`. - A runtime company profile with company name, city/state, and an approved, hash-bound terms template. - Optional versioned nesting handoff `1.0.0`. +- Optional versioned linear cut-list handoff `1.0.0` (from `steel-cutlist`). Resolve the company profile from `PI_STEEL_CONFIG`, project-local ignored `.pi-steel/company-profile.json`, then the platform user-config directory. @@ -51,6 +52,7 @@ The compiler owns: alternating row fills. - Exact total formulas covering the deterministic material range. - Versioned nesting/remnant reference rows with visible reference-only labels. +- Versioned linear stock / cut-list reference rows when a linear handoff is supplied. - Approved terms content and approval lineage. - Landscape print setup, one-page width, repeated row-8 headers, and stable project-derived filename. @@ -64,6 +66,7 @@ branding. python3 scripts/generate-rfq.py \ --input \ --nest \ + --linear \ --issued-date \ --project-location "Example City, ST" \ --out diff --git a/skills/steel-rfq/scripts/generate-rfq.py b/skills/steel-rfq/scripts/generate-rfq.py index 74747e1..333f29f 100755 --- a/skills/steel-rfq/scripts/generate-rfq.py +++ b/skills/steel-rfq/scripts/generate-rfq.py @@ -47,9 +47,13 @@ RFQ_COMPILER_VERSION = "1.0.0" NEST_HANDOFF_VERSION = "1.0.0" +LINEAR_HANDOFF_VERSION = "1.0.0" NEST_RESULT_SCHEMA_PATH = ( SHARED_ROOT / "schemas" / "nest-result.schema.json" ) +CUTLIST_RESULT_SCHEMA_PATH = ( + SHARED_ROOT / "schemas" / "cutlist-result.schema.json" +) HEADERS = [ "Item", "Category", @@ -547,6 +551,66 @@ def validate_nest_handoff( return findings +def _linear_handoff_validator() -> jsonschema.Draft202012Validator: + schema = json.loads(CUTLIST_RESULT_SCHEMA_PATH.read_text(encoding="utf-8")) + handoff_schema = { + "$schema": schema["$schema"], + "$ref": "#/$defs/linearHandoff", + "$defs": {"linearHandoff": schema["properties"]["rfq_linear"]}, + } + return jsonschema.Draft202012Validator(handoff_schema) + + +def validate_linear_handoff( + value: dict[str, Any] | None, + *, + expected: dict[str, Any] | None = None, +) -> list[dict[str, str]]: + if value is None: + return [] + if not isinstance(value, dict): + return [ + { + "code": "invalid_linear_handoff", + "severity": "error", + "path": "$", + "message": "Linear cut-list handoff must be a JSON object.", + } + ] + findings: list[dict[str, str]] = [] + for error in sorted( + _linear_handoff_validator().iter_errors(value), + key=lambda item: tuple(str(part) for part in item.absolute_path), + ): + findings.append( + { + "code": "invalid_linear_handoff_contract", + "severity": "error", + "path": _json_path(error.absolute_path), + "message": error.message, + } + ) + if expected is not None: + for handoff_field, expected_field in ( + ("project_id", "project_id"), + ("revision_id", "revision_id"), + ("estimate_input_hash", "input_hash"), + ): + if value.get(handoff_field) != expected.get(expected_field): + findings.append( + { + "code": "stale_linear_handoff", + "severity": "error", + "path": f"$.{handoff_field}", + "message": ( + f"Linear cut-list handoff {handoff_field} does not " + "match the current estimate package." + ), + } + ) + return findings + + def _color(value): if value is None: return None @@ -660,6 +724,7 @@ def compile_workbook( output_directory: str | Path, profile_source: str, bake: bool, + linear_handoff: dict[str, Any] | None = None, ) -> dict[str, Any]: profile_findings = validate_company_profile( profile, profile.get("_profile_path") @@ -675,6 +740,12 @@ def compile_workbook( "nest handoff blocked: " + "; ".join(finding["message"] for finding in nest_findings) ) + linear_findings = validate_linear_handoff(linear_handoff, expected=normalized) + if linear_findings: + raise RfqInputError( + "linear handoff blocked: " + + "; ".join(finding["message"] for finding in linear_findings) + ) workbook = Workbook() sheet = workbook.active @@ -887,7 +958,56 @@ def compile_workbook( cell.alignment = Alignment(vertical="top", wrap_text=True) nest_row += 1 - review_header_row = nest_row + 1 + linear_header_row = None + section_end_row = nest_row + if linear_handoff and linear_handoff.get("rows"): + linear_header_row = nest_row + 1 + sheet.merge_cells( + start_row=linear_header_row, + start_column=1, + end_row=linear_header_row, + end_column=14, + ) + sheet.cell( + linear_header_row, + 1, + "LINEAR STOCK / CUT-LIST REFERENCE (For Fabricator Review)", + ) + sheet.cell(linear_header_row, 1).font = Font( + name="Arial", bold=True, color=dark_blue + ) + linear_columns_row = linear_header_row + 1 + linear_headers = [ + "Designation", + "Grade", + "Bar Length (in)", + "Bars Needed", + "Cutting Plan", + "Drop Notes", + ] + for column, header in enumerate(linear_headers, start=1): + cell = sheet.cell(linear_columns_row, column, header) + cell.fill = PatternFill("solid", fgColor=medium_blue) + cell.font = Font(name="Arial", bold=True, color=white) + cell.border = border + linear_row = linear_columns_row + 1 + for entry in linear_handoff["rows"]: + values = [ + entry.get("designation"), + entry.get("grade"), + entry.get("bar_length_in"), + entry.get("bars_needed"), + entry.get("cutting_plan"), + entry.get("drop_notes"), + ] + for column, value in enumerate(values, start=1): + cell = sheet.cell(linear_row, column, value) + cell.border = border + cell.alignment = Alignment(vertical="top", wrap_text=True) + linear_row += 1 + section_end_row = linear_row + + review_header_row = section_end_row + 1 sheet.merge_cells( start_row=review_header_row, start_column=1, @@ -1002,6 +1122,7 @@ def compile_workbook( "last_material_row": last_material_row, "total_row": total_row, "nest_header_row": nest_header_row, + "linear_header_row": linear_header_row, "review_header_row": review_header_row, "terms_header_row": terms_header_row, }, @@ -1084,7 +1205,26 @@ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]: "message": str(exc), } ] - findings = input_findings + profile_findings + nest_findings + try: + linear_handoff = ( + json.loads(Path(args.linear).read_text(encoding="utf-8")) + if args.linear + else None + ) + linear_findings = validate_linear_handoff( + linear_handoff, + expected=normalized, + ) + except (OSError, json.JSONDecodeError) as exc: + linear_handoff = None + linear_findings = [ + { + "code": "invalid_linear_handoff", + "severity": "error", + "message": str(exc), + } + ] + findings = input_findings + profile_findings + nest_findings + linear_findings blockers = [finding for finding in findings if finding["severity"] == "error"] review_reasons = list(normalized["warnings"]) if any( @@ -1112,6 +1252,7 @@ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]: "project_location": args.project_location, "profile_hash": profile_semantic_hash(profile), "nest_handoff": nest_handoff, + "linear_handoff": linear_handoff, "bake_requested": not args.no_bake, } ) @@ -1138,6 +1279,7 @@ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]: "estimate_package": normalized["input_version"], "rfq_workbook": RFQ_COMPILER_VERSION, "rfq_nesting": NEST_HANDOFF_VERSION, + "rfq_linear": LINEAR_HANDOFF_VERSION, }, tool_versions={ "pi_steel": package_version(__file__), @@ -1154,6 +1296,7 @@ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]: normalized, profile, nest_handoff=nest_handoff, + linear_handoff=linear_handoff, issued_date=args.issued_date, project_location=args.project_location, output_directory=publisher.staging_path, @@ -1188,6 +1331,7 @@ def main(argv=None) -> int: ) parser.add_argument("--input", required=True) parser.add_argument("--nest") + parser.add_argument("--linear") parser.add_argument("--out", default="outputs") parser.add_argument("--issued-date", required=True) parser.add_argument("--project-location", default="") diff --git a/skills/steel-takeoff/scripts/validate-bom.py b/skills/steel-takeoff/scripts/validate-bom.py index 5ca83be..f44e21b 100755 --- a/skills/steel-takeoff/scripts/validate-bom.py +++ b/skills/steel-takeoff/scripts/validate-bom.py @@ -15,7 +15,7 @@ from bootstrap import bootstrap_shared # noqa: E402 bootstrap_shared(__file__) -from pi_steel.parsing import adapt_legacy_bom_csv # noqa: E402 +from pi_steel.parsing import adapt_legacy_bom_csv, normalize_designation # noqa: E402 from pi_steel.validation import validate_estimate_package # noqa: E402 @@ -27,10 +27,6 @@ def load_shapes_db() -> dict[str, dict]: return {shape["designation"]: shape for shape in shapes} -def normalize_designation(raw: str) -> str: - return raw.upper().replace(" ", "").strip() - - def grade_warnings(shape_type: str, grade: str) -> list[str]: normalized = grade.upper().replace(" ", "").replace(".", "") expected = { diff --git a/tests/golden/pipeline/ready-artifacts.json b/tests/golden/pipeline/ready-artifacts.json index 0247fa3..273d711 100644 --- a/tests/golden/pipeline/ready-artifacts.json +++ b/tests/golden/pipeline/ready-artifacts.json @@ -1,10 +1,13 @@ { "artifact_paths": [ "Synthetic_Pipeline_Project_RFQ_Material_List.xlsx", + "cutlist-result.json", + "cutting_list.csv", "estimate-package.json", "nest-result.json", "normalized-bom.json", "qa-report.json", + "rfq-linear.json", "rfq-nesting.json", "workbook-semantic.json" ], diff --git a/tests/test_cutlist_cli_contract.py b/tests/test_cutlist_cli_contract.py new file mode 100644 index 0000000..1e96209 --- /dev/null +++ b/tests/test_cutlist_cli_contract.py @@ -0,0 +1,182 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import jsonschema + + +ROOT = Path(__file__).resolve().parents[1] +CUTLIST_SCRIPT = ROOT / "skills" / "steel-cutlist" / "scripts" / "cutlist.py" +CUTLIST_SCHEMA = ROOT / "skills" / "_shared" / "schemas" / "cutlist-result.schema.json" + + +def run_cli(tmp_path, job, run_id, extra_args=()): + job_path = tmp_path / f"{run_id}.json" + job_path.write_text(json.dumps(job), encoding="utf-8") + output = tmp_path / "published" + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [ + sys.executable, + CUTLIST_SCRIPT, + "--job", + job_path, + "--out", + output, + "--run-id", + run_id, + "--no-render", + *extra_args, + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + ) + pointer = json.loads((output / "latest-run.json").read_text()) + run_path = output / pointer["run_directory"] + return completed, run_path + + +def valid_job(): + return { + "job_name": "SYNTHETIC-CUTLIST-CLI", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0.125, "end_trim_in": 0.25, "min_drop_in": 24}, + "members": [ + { + "source_id": "SYNTHETIC-CLI-M1", + "name": "SYNTHETIC-CLI-B1", + "designation": "W12X26", + "grade": "A992", + "length_in": 200, + "qty": 2, + } + ], + "stock": [ + { + "stock_id": "SYNTHETIC-CLI-STK", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 1, + "cost_per_ft": 30.0, + } + ], + } + + +def test_ready_run_publishes_verified_cutting_list_and_handoff(tmp_path): + completed, run_path = run_cli(tmp_path, valid_job(), "synthetic-cutlist-ready") + assert completed.returncode == 0, completed.stderr + manifest = json.loads((run_path / "run-manifest.json").read_text()) + assert manifest["run_outcome"] == "ready" + assert manifest["package_status"] == "cutlist_verified" + artifacts = {entry["path"]: entry for entry in manifest["artifacts"]} + assert artifacts["cutting_list.csv"]["readiness"] == "geometry_verified" + assert "rfq_linear.json" in artifacts + + result = json.loads((run_path / "result.json").read_text()) + schema = json.loads(CUTLIST_SCHEMA.read_text()) + jsonschema.validate(result, schema) + assert result["verification"]["status"] == "verified" + assert result["bars_used"] == 1 + assert result["total_material_cost"] == 1200.0 + + cutting_list = (run_path / "cutting_list.csv").read_text().splitlines() + assert cutting_list[0].startswith("Bar,Stock,Designation") + assert len(cutting_list) == 3 + + handoff = json.loads((run_path / "rfq_linear.json").read_text()) + assert handoff["schema_version"] == "1.0.0" + assert handoff["rows"][0]["bars_needed"] == 1 + + +def test_blocked_run_suppresses_cutting_list_and_exits_3(tmp_path): + job = valid_job() + job["members"][0]["length_in"] = 500 + completed, run_path = run_cli(tmp_path, job, "synthetic-cutlist-blocked") + assert completed.returncode == 3 + manifest = json.loads((run_path / "run-manifest.json").read_text()) + assert manifest["run_outcome"] == "blocked" + assert manifest["package_status"] == "cutlist_partial" + assert not (run_path / "cutting_list.csv").exists() + assert not (run_path / "rfq_linear.json").exists() + + qa_report = json.loads((run_path / "qa-report.json").read_text()) + assert any( + finding["code"] == "UNPLACED_MEMBERS" for finding in qa_report["findings"] + ) + result = json.loads((run_path / "result.json").read_text()) + schema = json.loads(CUTLIST_SCHEMA.read_text()) + jsonschema.validate(result, schema) + assert result["unplaced"][0]["reason"] == "no_compatible_stock_fit" + + +def test_unreadable_job_publishes_failure_diagnostic_and_exits_1(tmp_path): + output = tmp_path / "published" + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [ + sys.executable, + CUTLIST_SCRIPT, + "--job", + tmp_path / "missing.json", + "--out", + output, + "--run-id", + "synthetic-cutlist-missing", + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + ) + assert completed.returncode == 1 + pointer = json.loads((output / "latest-run.json").read_text()) + run_path = output / pointer["run_directory"] + qa_report = json.loads((run_path / "qa-report.json").read_text()) + assert qa_report["run_outcome"] == "usage_or_internal_error" + + +def test_usage_error_exits_1_and_publishes_diagnostic(tmp_path): + output = tmp_path / "published" + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [ + sys.executable, + CUTLIST_SCRIPT, + "--out", + output, + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + ) + assert completed.returncode == 1 + assert (output / "latest-run.json").exists() + + +def test_example_job_reference_runs_ready(tmp_path): + example = json.loads( + ( + ROOT + / "skills" + / "steel-cutlist" + / "references" + / "example_job.json" + ).read_text() + ) + completed, run_path = run_cli(tmp_path, example, "synthetic-cutlist-example") + assert completed.returncode == 0, completed.stderr + result = json.loads((run_path / "result.json").read_text()) + assert result["outcome"] == "ready" + assert result["purchase_summary"] diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py new file mode 100644 index 0000000..0c595ff --- /dev/null +++ b/tests/test_cutlist_engine.py @@ -0,0 +1,533 @@ +import importlib.util +import sys +from copy import deepcopy +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SHARED = ROOT / "skills" / "_shared" +sys.path.insert(0, str(SHARED)) +CUTLIST_SCRIPT = ROOT / "skills" / "steel-cutlist" / "scripts" / "cutlist.py" +SPEC = importlib.util.spec_from_file_location("pi_steel_cutlist_engine", CUTLIST_SCRIPT) +cutlist = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = cutlist +SPEC.loader.exec_module(cutlist) + +from pi_steel import canonical_json_bytes, sha256_bytes # noqa: E402 + + +def base_job(): + return { + "job_name": "SYNTHETIC-CUTLIST-ENGINE", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0.125, "end_trim_in": 0.25, "min_drop_in": 24}, + "members": [ + { + "source_id": "SYNTHETIC-M1", + "name": "SYNTHETIC-B1", + "designation": "W12X26", + "grade": "A992", + "length_in": 342, + "qty": 4, + }, + { + "source_id": "SYNTHETIC-M2", + "name": "SYNTHETIC-B2", + "designation": "W12X26", + "grade": "A992", + "length_ft": "12'-6", + "qty": 6, + }, + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-40", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "unlimited": True, + "cost_per_ft": 31.2, + }, + { + "stock_id": "SYNTHETIC-STK-50", + "designation": "W12X26", + "grade": "A992", + "length_ft": 50, + "unlimited": True, + "cost_per_ft": 39.0, + }, + ], + } + + +def test_exact_fit_consumes_bar_without_trailing_kerf_rejection(): + job = { + **base_job(), + "members": [ + { + "source_id": "SYNTHETIC-EXACT", + "name": "SYNTHETIC-EXACT", + "designation": "W12X26", + "grade": "A992", + "length_in": 479.5, + "qty": 1, + } + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-EXACT", + "designation": "W12X26", + "grade": "A992", + "length_in": 480, + "qty": 1, + } + ], + } + result = cutlist.run_job(job) + assert result["outcome"] == "ready" + assert result["unplaced"] == [] + report = result["bar_reports"][0] + assert report["usable_length_in"] == 479.5 + assert report["drop_in"] == 0 + assert result["fit_contract"]["consumption_per_piece"] == ( + "length_plus_one_kerf_saturating" + ) + + +def test_feet_inches_string_parses_and_kerf_accounting(): + result = cutlist.run_job(base_job()) + lengths = sorted( + {cut["length_in"] for report in result["bar_reports"] for cut in report["cuts"]} + ) + assert lengths == [150, 342] + for report in result["bar_reports"]: + assert report["kerf_total_in"] == round(0.125 * report["num_cuts"], 3) + consumed = report["cut_length_in"] + report["kerf_total_in"] + assert consumed <= report["usable_length_in"] + report["num_cuts"] * 0.125 + + +def test_portfolio_prefers_cheaper_single_length_at_equal_total_length(): + result = cutlist.run_job(base_job()) + assert result["outcome"] == "ready" + assert result["unplaced"] == [] + summary = result["purchase_summary"] + assert len(summary) == 1 + assert summary[0]["stock_id"] == "SYNTHETIC-STK-40" + assert summary[0]["bars_needed"] == 6 + assert result["total_material_cost"] == 7488.0 + + +def test_incompatible_groups_never_mix_and_missing_stock_blocks(): + job = base_job() + job["members"].append( + { + "source_id": "SYNTHETIC-M3", + "name": "SYNTHETIC-ANGLE", + "designation": "L4X4X1/4", + "grade": "A36", + "length_in": 100, + "qty": 2, + } + ) + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert result["package_status"] == "cutlist_partial" + unplaced = {row["designation"]: row for row in result["unplaced"]} + assert unplaced["L4X4X1/4"]["reason"] == "no_compatible_stock_fit" + assert unplaced["L4X4X1/4"]["quantity"] == 2 + for report in result["bar_reports"]: + for cut in report["cuts"]: + assert (cut["designation"], cut["grade"]) == ( + report["designation"], + report["grade"], + ) + + +def test_finite_stock_exhaustion_reports_reason_and_partial_status(): + job = base_job() + job["stock"] = [ + { + "stock_id": "SYNTHETIC-STK-FINITE", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 1, + } + ] + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert result["package_status"] == "cutlist_partial" + assert all(row["reason"] == "stock_exhausted" for row in result["unplaced"]) + assert result["cost"]["status"] == "incomplete_unplaced" + assert result["total_material_cost"] is None + + +def test_ambiguous_length_basis_is_an_error_finding(): + job = base_job() + job["members"][0]["length_ft"] = 28.5 + both = cutlist.run_job(job) + assert any( + finding["code"] == "ambiguous_length_basis" + for finding in both["validation_findings"] + ) + assert both["outcome"] == "blocked" + + job = base_job() + del job["members"][0]["length_in"] + neither = cutlist.run_job(job) + assert any( + finding["code"] == "ambiguous_length_basis" + for finding in neither["validation_findings"] + ) + + +def test_aisc_weight_lookup_and_unknown_weight_warning(): + known = cutlist.run_job(base_job()) + cut = known["bar_reports"][0]["cuts"][0] + assert cut["weight_basis"] == "aisc_database" + assert known["weight_status"] == "known" + expected = round(26.0 * cut["length_in"] / 12.0, 1) + assert cut["weight_lbs"] == expected + + job = base_job() + job["members"][0]["designation"] = "W12X999" + job["stock"].append( + { + "stock_id": "SYNTHETIC-STK-UNKNOWN", + "designation": "W12X999", + "grade": "A992", + "length_ft": 40, + "unlimited": True, + } + ) + unknown = cutlist.run_job(job) + assert any( + finding["code"] == "unknown_unit_weight" + and finding["severity"] == "warning" + for finding in unknown["validation_findings"] + ) + assert unknown["weight_status"] == "incomplete" + assert unknown["total_cut_weight_lbs"] is None + assert unknown["outcome"] == "ready" + + +def test_declared_unit_weight_overrides_database(): + job = base_job() + job["members"][0]["unit_weight_plf"] = 30.0 + result = cutlist.run_job(job) + declared = [ + cut + for report in result["bar_reports"] + for cut in report["cuts"] + if cut["weight_basis"] == "declared" + ] + assert declared + assert declared[0]["weight_lbs"] == round(30.0 * declared[0]["length_in"] / 12.0, 1) + + +def test_conflicting_cost_basis_is_an_error(): + job = base_job() + job["stock"][0]["cost_per_bar"] = 1000 + result = cutlist.run_job(job) + assert any( + finding["code"] == "conflicting_cost_basis" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "blocked" + + +def test_drop_classification_threshold(): + result = cutlist.run_job(base_job()) + for report in result["bar_reports"]: + expected = ( + "reusable_candidate" if report["drop_in"] >= 24 else "offcut" + ) + assert report["drop_class"] == expected + for drop in result["drops"]: + assert drop["length_in"] >= 24 + assert drop["status"] == "candidate_unverified" + + +def test_determinism_same_input_same_result_hash(): + first = cutlist.run_job(deepcopy(base_job())) + second = cutlist.run_job(deepcopy(base_job())) + assert sha256_bytes(canonical_json_bytes(first)) == sha256_bytes( + canonical_json_bytes(second) + ) + + +def test_every_instance_is_cut_exactly_once_or_reported_unplaced(): + job = base_job() + job["stock"] = [ + { + "stock_id": "SYNTHETIC-STK-FINITE", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 3, + } + ] + result = cutlist.run_job(job) + cut_instances = [ + cut["instance_id"] + for report in result["bar_reports"] + for cut in report["cuts"] + ] + assert len(cut_instances) == len(set(cut_instances)) + total = len(cut_instances) + sum(row["quantity"] for row in result["unplaced"]) + assert total == 10 + assert result["verification"]["status"] == "verified" + + +def test_independent_verifier_catches_tampered_bars(): + result = cutlist.run_job(base_job()) + reports = deepcopy(result["bar_reports"]) + reports[0]["cuts"].append({**reports[0]["cuts"][0]}) + expected = { + cut["instance_id"] + for report in result["bar_reports"] + for cut in report["cuts"] + } + findings = cutlist.verify_cutlist_bars( + reports, kerf=0.125, expected_instances=expected + ) + codes = {finding["code"] for finding in findings} + assert "DUPLICATE_PLACEMENT" in codes + assert "BAR_OVERCOMMITTED" in codes + + tampered = deepcopy(result["bar_reports"]) + tampered[0]["cuts"][0]["grade"] = "A36" + findings = cutlist.verify_cutlist_bars( + tampered, kerf=0.125, expected_instances=expected + ) + assert any( + finding["code"] == "BAR_MATERIAL_MISMATCH" for finding in findings + ) + + missing = deepcopy(result["bar_reports"]) + removed = missing[0]["cuts"].pop() + findings = cutlist.verify_cutlist_bars( + missing, kerf=0.125, expected_instances=expected + ) + assert any( + finding["code"] == "INSTANCE_UNACCOUNTED" + and removed["instance_id"] in finding["message"] + for finding in findings + ) + + +def test_unusable_stock_length_is_an_error(): + job = base_job() + job["settings"]["end_trim_in"] = 300 + result = cutlist.run_job(job) + assert any( + finding["code"] == "unusable_stock_length" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "blocked" + + +def test_designation_normalization_matches_across_case_and_spaces(): + job = base_job() + job["members"][0]["designation"] = "w12 x 26" + result = cutlist.run_job(job) + assert result["unplaced"] == [] + assert result["outcome"] == "ready" + + +def test_rfq_linear_block_carries_identity_and_rows(): + result = cutlist.run_job(base_job()) + handoff = result["rfq_linear"] + assert handoff["schema_version"] == "1.0.0" + assert handoff["project_id"] == "SYNTHETIC-PRJ" + assert handoff["estimate_input_hash"] == result["estimate_input_hash"] + assert len(handoff["rows"]) == len(result["purchase_summary"]) + row = handoff["rows"][0] + assert row["bars_needed"] == 6 + assert "cutting_plan" in row and "drop_notes" in row + + +def test_negative_quantity_blocks_without_crashing(): + job = base_job() + job["members"][0]["qty"] = -3 + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert any( + finding["code"] == "invalid_quantity" + for finding in result["validation_findings"] + ) + assert result["verification"]["status"] == "not_run" + + +def test_blocked_runs_report_verification_not_run(): + job = base_job() + del job["members"][0]["grade"] + job["grade"] = None + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert result["verification"]["status"] == "not_run" + + ready = cutlist.run_job(base_job()) + assert ready["verification"]["status"] == "verified" + + +def test_rfq_linear_rows_carry_per_group_utilization(): + job = base_job() + job["members"].append( + { + "source_id": "SYNTHETIC-M-HSS", + "name": "SYNTHETIC-HSS", + "designation": "HSS6X6X1/2", + "grade": "A500B", + "length_in": 200, + "qty": 1, + "unit_weight_plf": 35.24, + } + ) + job["stock"].append( + { + "stock_id": "SYNTHETIC-STK-HSS", + "designation": "HSS6X6X1/2", + "grade": "A500B", + "length_ft": 40, + "unlimited": True, + } + ) + result = cutlist.run_job(job) + rows = {row["stock_id"]: row for row in result["rfq_linear"]["rows"]} + for stock_id, row in rows.items(): + matching = [ + report + for report in result["bar_reports"] + if report["stock_id"] == stock_id + ] + expected = round( + 100 + * sum(report["cut_length_in"] for report in matching) + / sum(report["bar_length_in"] for report in matching), + 1, + ) + assert row["utilization_pct"] == expected + assert len({row["utilization_pct"] for row in rows.values()}) > 1 + + +def test_cutting_list_preserves_sixteenth_inch_precision(): + job = base_job() + job["members"] = [ + { + "source_id": "SYNTHETIC-M-PRECISE", + "name": "SYNTHETIC-PRECISE", + "designation": "W12X26", + "grade": "A992", + "length_in": 342.0625, + "qty": 1, + } + ] + result = cutlist.run_job(job) + csv_text = cutlist.render_cutting_list_csv(result) + assert "342.0625" in csv_text + + +def test_on_hand_stick_covering_members_eliminates_purchasing(): + job = base_job() + job["members"] = [ + { + "source_id": "SYNTHETIC-M-SHORT", + "name": "SYNTHETIC-SHORT", + "designation": "W12X26", + "grade": "A992", + "length_in": 144, + "qty": 2, + } + ] + job["stock"].append( + { + "stock_id": "SYNTHETIC-ONHAND-30", + "stock_kind": "on_hand", + "designation": "W12X26", + "grade": "A992", + "length_ft": 30, + "qty": 1, + } + ) + result = cutlist.run_job(job) + assert result["outcome"] == "ready" + assert result["bars_used"] == 1 + report = result["bar_reports"][0] + assert report["stock_kind"] == "on_hand" + assert report["cost_basis"] == "on_hand" + assert report["bar_cost"] is None + summary = result["purchase_summary"][0] + assert summary["stock_kind"] == "on_hand" + assert result["cost"]["status"] == "known" + assert result["total_material_cost"] == 0.0 + assert result["rfq_linear"]["rows"][0]["stock_kind"] == "on_hand" + + +def test_on_hand_stock_rejects_unlimited_and_cost_basis(): + job = base_job() + job["stock"].append( + { + "stock_id": "SYNTHETIC-ONHAND-BAD", + "stock_kind": "on_hand", + "designation": "W12X26", + "grade": "A992", + "length_ft": 30, + "unlimited": True, + "cost_per_ft": 10.0, + } + ) + result = cutlist.run_job(job) + codes = {finding["code"] for finding in result["validation_findings"]} + assert "unlimited_on_hand_stock" in codes + assert "cost_basis_on_hand_stock" in codes + assert result["outcome"] == "blocked" + + +def test_invalid_stock_kind_is_an_error(): + job = base_job() + job["stock"][0]["stock_kind"] = "borrowed" + result = cutlist.run_job(job) + assert any( + finding["code"] == "invalid_stock_kind" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "blocked" + + +def test_string_unlimited_flag_is_rejected_not_coerced(): + job = base_job() + job["stock"][0]["unlimited"] = "false" + result = cutlist.run_job(job) + assert any( + finding["code"] == "invalid_unlimited_flag" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "blocked" + + +def test_anonymous_on_hand_and_purchasable_rows_get_distinct_ids(): + job = base_job() + job["stock"] = [ + { + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 5, + }, + { + "stock_kind": "on_hand", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 1, + }, + ] + result = cutlist.run_job(job) + assert not any( + finding["code"] == "duplicate_stock_id" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "ready" diff --git a/tests/test_estimate_pipeline.py b/tests/test_estimate_pipeline.py index acc3375..b5efdf4 100644 --- a/tests/test_estimate_pipeline.py +++ b/tests/test_estimate_pipeline.py @@ -393,3 +393,203 @@ def test_no_vendor_supply_items_publish_blocked_diagnostics(tmp_path): assert not list(run_path.glob("*.xlsx")) qa = load_json(run_path / "qa-report.json") assert any(finding["code"] == "rfq_input_blocked" for finding in qa["findings"]) + + +def test_ready_pipeline_publishes_verified_cutlist_and_linear_handoff(tmp_path): + completed, run_path = run_pipeline( + tmp_path, load_package(), "SYNTHETIC-PIPELINE-CUTLIST" + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + manifest = load_json(run_path / "run-manifest.json") + cutlist = load_json(run_path / "cutlist-result.json") + assert cutlist["outcome"] == "ready" + assert cutlist["verification"]["status"] == "verified" + assert cutlist["estimate_input_hash"] == manifest["input_hash"] + assert {row["designation"] for row in cutlist["purchase_summary"]} == { + "W12X26", + "HSS6X6X3/8", + } + assert cutlist["weight_status"] == "known" + + handoff = load_json(run_path / "rfq-linear.json") + assert handoff["schema_version"] == "1.0.0" + assert handoff["estimate_input_hash"] == manifest["input_hash"] + + cutting_list = (run_path / "cutting_list.csv").read_text().splitlines() + assert cutting_list[0].startswith("Bar,Stock,Designation") + assert len(cutting_list) > 1 + + qa_report = load_json(run_path / "qa-report.json") + assert qa_report["cutlist"]["outcome"] == "ready" + assert qa_report["cutlist"]["weight_status"] == "known" + + workbook = openpyxl.load_workbook(next(run_path.glob("*.xlsx"))) + values = { + cell.value + for row in workbook["RFQ Draft"].iter_rows() + for cell in row + if isinstance(cell.value, str) + } + assert "LINEAR STOCK / CUT-LIST REFERENCE (For Fabricator Review)" in values + + +def test_member_exceeding_all_mill_lengths_blocks_as_cutlist_partial(tmp_path): + package = load_package() + for item in package["items"]: + if item.get("mark") == "W1": + item["length_ft"] = 70 + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-CUTLIST-BLOCKED" + ) + assert completed.returncode == 3 + manifest = load_json(run_path / "run-manifest.json") + assert manifest["run_outcome"] == "blocked" + assert manifest["package_status"] == "cutlist_partial" + qa_report = load_json(run_path / "qa-report.json") + assert any( + finding["code"] == "unplaced_members" for finding in qa_report["findings"] + ) + assert not (run_path / "cutting_list.csv").exists() + cutlist = load_json(run_path / "cutlist-result.json") + assert cutlist["unplaced"][0]["reason"] == "no_compatible_stock_fit" + + +def test_member_without_grade_is_excluded_from_cutlist_not_blocked(tmp_path): + package = load_package() + for item in package["items"]: + if item.get("mark") == "W1": + del item["grade"] + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-NO-GRADE" + ) + assert completed.returncode == 2, completed.stdout + completed.stderr + manifest = load_json(run_path / "run-manifest.json") + assert manifest["run_outcome"] == "review_required" + assert next(run_path.glob("*.xlsx"), None) is not None + qa_report = load_json(run_path / "qa-report.json") + assert any( + finding["code"] == "member_missing_grade_excluded_from_cutlist" + and finding["severity"] == "warning" + for finding in qa_report["findings"] + ) + cutlist = load_json(run_path / "cutlist-result.json") + marks = { + cut["label"] + for report in cutlist["bar_reports"] + for cut in report["cuts"] + } + assert "W1" not in marks + assert "HSS1" in marks + assert not (run_path / "cutting_list.csv").exists() + + +def test_nonfinite_mill_lengths_fail_as_usage_error(tmp_path): + input_path = tmp_path / "input.json" + input_path.write_text(json.dumps(load_package()), encoding="utf-8") + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [ + sys.executable, + SCRIPT, + "--input", + input_path, + "--out", + tmp_path / "published", + "--prepared-date", + "2026-07-28", + "--issued-date", + "2026-07-29", + "--mill-lengths-ft", + "nan", + "--no-render", + "--no-bake", + ], + cwd=tmp_path, + env=environment, + text=True, + capture_output=True, + ) + assert completed.returncode == 1 + assert "finite" in completed.stderr + + +def test_declared_vendor_linear_stock_overrides_default_mill_lengths(tmp_path): + package = load_package() + package["stock"].append( + { + "stock_kind": "purchasable", + "stock_form": "linear", + "inventory_id": "SYNTHETIC-VENDOR-W12-45", + "designation": "W12X26", + "grade": "A992", + "length_ft": 45, + "quantity": 1, + "unlimited": True, + } + ) + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-VENDOR-LINEAR" + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + cutlist = load_json(run_path / "cutlist-result.json") + rows = {row["designation"]: row for row in cutlist["purchase_summary"]} + assert rows["W12X26"]["stock_id"] == "SYNTHETIC-VENDOR-W12-45" + assert rows["W12X26"]["bar_length_in"] == 540.0 + assert rows["HSS6X6X3/8"]["stock_id"].startswith("mill:") + + +def test_confirmed_on_hand_linear_stick_eliminates_group_purchase(tmp_path): + package = load_package() + stick = { + "stock_kind": "on_hand", + "stock_form": "linear", + "inventory_id": "SYNTHETIC-YARD-W12-30", + "designation": "W12X26", + "grade": "A992", + "length_ft": 30, + "quantity": 1, + "status": "available", + "measured_at": "2026-07-27", + "source": "synthetic yard count", + } + package["stock"].append(stick) + stick["reviewer_confirmation"] = { + "actor": "Synthetic Reviewer", + "timestamp": "2026-07-28T00:00:00Z", + "estimate_hash": estimate_input_hash(package), + } + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-ONHAND-LINEAR" + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + cutlist = load_json(run_path / "cutlist-result.json") + rows = {row["designation"]: row for row in cutlist["purchase_summary"]} + assert rows["W12X26"]["stock_id"] == "SYNTHETIC-YARD-W12-30" + assert rows["W12X26"]["stock_kind"] == "on_hand" + assert rows["W12X26"]["total_cost"] is None + assert rows["HSS6X6X3/8"]["stock_kind"] == "purchasable" + + +def test_unconfirmed_on_hand_linear_stick_blocks_validation(tmp_path): + package = load_package() + package["stock"].append( + { + "stock_kind": "on_hand", + "stock_form": "linear", + "inventory_id": "SYNTHETIC-YARD-UNCONFIRMED", + "designation": "W12X26", + "grade": "A992", + "length_ft": 30, + "quantity": 1, + } + ) + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-ONHAND-UNCONFIRMED" + ) + assert completed.returncode == 3 + qa_report = load_json(run_path / "qa-report.json") + assert any( + finding["code"] == "unconfirmed_on_hand_stock" + for finding in qa_report["findings"] + ) diff --git a/tests/test_full_render_smoke.py b/tests/test_full_render_smoke.py index 28c8d4f..7e9a91c 100644 --- a/tests/test_full_render_smoke.py +++ b/tests/test_full_render_smoke.py @@ -137,7 +137,10 @@ def test_ready_package_renders_reference_and_verified_outputs(tmp_path): for line in info.splitlines() if line.startswith("Pages:") ) - assert page_count == 1 + # The workbook contract is one page WIDE (fitToWidth=1, fitToHeight=0); + # vertical pagination varies with content and the LibreOffice version. + # The cap only guards against a runaway layout regression. + assert 1 <= page_count <= 3 text_output = tmp_path / "workbook.txt" extracted = subprocess.run( @@ -164,22 +167,41 @@ def test_ready_package_renders_reference_and_verified_outputs(tmp_path): ) root = ElementTree.parse(bbox_output).getroot() pages = [element for element in root.iter() if element.tag.endswith("page")] - words = [element for element in root.iter() if element.tag.endswith("word")] - assert len(pages) == 1 - page_width = float(pages[0].attrib["width"]) - page_height = float(pages[0].attrib["height"]) - assert words - for word in words: - assert 0 <= float(word.attrib["xMin"]) < float(word.attrib["xMax"]) <= page_width - assert 0 <= float(word.attrib["yMin"]) < float(word.attrib["yMax"]) <= page_height + assert len(pages) == page_count + first_width = float(pages[0].attrib["width"]) + for page in pages: + assert float(page.attrib["width"]) == first_width + page_width = float(page.attrib["width"]) + page_height = float(page.attrib["height"]) + page_words = [ + element for element in page.iter() if element.tag.endswith("word") + ] + assert page_words + for word in page_words: + assert 0 <= float(word.attrib["xMin"]) < float(word.attrib["xMax"]) <= page_width + assert 0 <= float(word.attrib["yMin"]) < float(word.attrib["yMax"]) <= page_height - word_positions = { - (word.text or "").upper(): float(word.attrib["yMin"]) for word in words - } - assert word_positions["REQUEST"] < page_height * 0.2 + first_page_width = float(pages[0].attrib["width"]) + first_page_height = float(pages[0].attrib["height"]) + words = [ + element for element in pages[0].iter() if element.tag.endswith("word") + ] + + # Document-order positions: (page index, yMin) compares across pages. + word_positions = {} + page_heights = {} + for page_index, page in enumerate(pages): + for word in page.iter(): + if not word.tag.endswith("word"): + continue + key = (word.text or "").upper() + position = (page_index, float(word.attrib["yMin"])) + word_positions.setdefault(key, position) + page_heights[key] = float(page.attrib["height"]) + assert word_positions["REQUEST"] < (0, first_page_height * 0.2) assert word_positions["RESPONSE"] < word_positions["TOTAL"] assert word_positions["TOTAL"] < word_positions["TERMS"] - assert word_positions["TERMS"] < page_height * 0.95 + assert word_positions["TERMS"][1] < page_heights["TERMS"] * 0.95 images = subprocess.run( [tools["pdfimages"], "-list", workbook_pdf], @@ -229,12 +251,12 @@ def test_ready_package_renders_reference_and_verified_outputs(tmp_path): logo_rows, logo_columns = numpy.where(synthetic_logo) assert logo_rows.size and logo_columns.size logo_bounds = ( - logo_columns.min() / raster.shape[1] * page_width, - logo_rows.min() / raster.shape[0] * page_height, - logo_columns.max() / raster.shape[1] * page_width, - logo_rows.max() / raster.shape[0] * page_height, + logo_columns.min() / raster.shape[1] * first_page_width, + logo_rows.min() / raster.shape[0] * first_page_height, + logo_columns.max() / raster.shape[1] * first_page_width, + logo_rows.max() / raster.shape[0] * first_page_height, ) - assert logo_bounds[1] < page_height * 0.2 + assert logo_bounds[1] < first_page_height * 0.2 for word in words: if (word.text or "").upper() not in {"RESPONSE", "TOTAL", "TERMS"}: continue