@structupath/pi-steel 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/DATA_PROVENANCE.json +23 -0
  2. package/DATA_PROVENANCE.md +35 -0
  3. package/PUBLIC_DATA_POLICY.md +52 -0
  4. package/README.md +114 -33
  5. package/docs/assets/pi-steel-demo.gif +0 -0
  6. package/docs/assets/pi-steel-gallery.webp +0 -0
  7. package/package.json +35 -4
  8. package/pyproject.toml +28 -0
  9. package/requirements-dev.txt +5 -0
  10. package/requirements-render.txt +1 -0
  11. package/requirements-tested.txt +9 -0
  12. package/requirements.txt +7 -0
  13. package/scripts/check-data-provenance.py +140 -0
  14. package/scripts/check-public-data.py +401 -0
  15. package/scripts/doctor.py +127 -0
  16. package/skills/_shared/bootstrap.py +32 -0
  17. package/skills/_shared/pi_steel/__init__.py +47 -0
  18. package/skills/_shared/pi_steel/cli.py +167 -0
  19. package/skills/_shared/pi_steel/contracts.py +87 -0
  20. package/skills/_shared/pi_steel/geometry_verify.py +243 -0
  21. package/skills/_shared/pi_steel/parsing.py +205 -0
  22. package/skills/_shared/pi_steel/run_manifest.py +317 -0
  23. package/skills/_shared/pi_steel/validation.py +597 -0
  24. package/skills/_shared/schemas/estimate-package.schema.json +424 -0
  25. package/skills/_shared/schemas/nest-result.schema.json +443 -0
  26. package/skills/_shared/schemas/run-manifest.schema.json +145 -0
  27. package/skills/steel-estimate/SKILL.md +119 -0
  28. package/skills/steel-estimate/references/estimate-package-example.json +91 -0
  29. package/skills/steel-estimate/references/output-contract.md +47 -0
  30. package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
  31. package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
  32. package/skills/steel-nest/SKILL.md +50 -23
  33. package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
  34. package/skills/steel-nest/references/example_job.json +21 -25
  35. package/skills/steel-nest/references/job_template.json +11 -9
  36. package/skills/steel-nest/scripts/nest.py +1276 -250
  37. package/skills/steel-rfq/SKILL.md +93 -175
  38. package/skills/steel-rfq/assets/company-profile.example.json +11 -5
  39. package/skills/steel-rfq/references/rfq-input.md +58 -0
  40. package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
  41. package/skills/steel-rfq/scripts/recalc.py +33 -10
  42. package/skills/steel-takeoff/SKILL.md +12 -8
  43. package/skills/steel-takeoff/assets/bom-template.csv +1 -1
  44. package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
  45. package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
  46. package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
  47. package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
@@ -0,0 +1,167 @@
1
+ """Shared command-line behavior for shipped pi-steel stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .run_manifest import (
12
+ ManifestError,
13
+ RunPublisher,
14
+ canonical_json_bytes,
15
+ sha256_bytes,
16
+ sha256_file,
17
+ )
18
+
19
+
20
+ class StageArgumentParser(argparse.ArgumentParser):
21
+ """Map command usage errors to the shared stage-contract exit code."""
22
+
23
+ def configure_failure_diagnostics(
24
+ self,
25
+ *,
26
+ stage: str,
27
+ entry_file: str,
28
+ input_option: str,
29
+ date_options: dict[str, str] | None = None,
30
+ ) -> None:
31
+ self._failure_context = {
32
+ "stage": stage,
33
+ "entry_file": entry_file,
34
+ "input_option": input_option,
35
+ "date_options": date_options or {},
36
+ }
37
+
38
+ def parse_args(self, args=None, namespace=None):
39
+ self._active_argv = list(sys.argv[1:] if args is None else args)
40
+ return super().parse_args(args, namespace)
41
+
42
+ def _option_value(self, option: str) -> str | None:
43
+ argv = getattr(self, "_active_argv", [])
44
+ for index, argument in enumerate(argv):
45
+ if argument == option and index + 1 < len(argv):
46
+ return str(argv[index + 1])
47
+ if str(argument).startswith(f"{option}="):
48
+ return str(argument).split("=", 1)[1]
49
+ action = next(
50
+ (item for item in self._actions if option in item.option_strings),
51
+ None,
52
+ )
53
+ if action is not None and action.default not in (None, argparse.SUPPRESS):
54
+ return str(action.default)
55
+ return None
56
+
57
+ def error(self, message):
58
+ diagnostic_path = None
59
+ context = getattr(self, "_failure_context", None)
60
+ if context is not None:
61
+ destination = self._option_value("--out")
62
+ if destination is not None:
63
+ explicit_dates = {
64
+ field: value
65
+ for option, field in context["date_options"].items()
66
+ if (value := self._option_value(option)) is not None
67
+ }
68
+ diagnostic_path = publish_failure_diagnostic(
69
+ destination,
70
+ stage=context["stage"],
71
+ input_path=self._option_value(context["input_option"]),
72
+ error=ValueError(message),
73
+ tool_version=package_version(context["entry_file"]),
74
+ run_id=self._option_value("--run-id"),
75
+ explicit_dates=explicit_dates,
76
+ finding_code="cli_usage_error",
77
+ )
78
+ self.print_usage(sys.stderr)
79
+ suffix = (
80
+ f"; diagnostic published: {diagnostic_path}"
81
+ if diagnostic_path is not None
82
+ else ""
83
+ )
84
+ self.exit(1, f"{self.prog}: error: {message}{suffix}\n")
85
+
86
+
87
+ def package_version(entry_file: str) -> str:
88
+ package_path = Path(entry_file).resolve().parents[3] / "package.json"
89
+ try:
90
+ return json.loads(package_path.read_text(encoding="utf-8"))["version"]
91
+ except (OSError, KeyError, json.JSONDecodeError):
92
+ return "unknown"
93
+
94
+
95
+ def _diagnostic_message(error: Exception) -> str:
96
+ """Describe a failure without persisting input paths in published artifacts."""
97
+ if isinstance(error, OSError):
98
+ detail = error.strerror or "input/output error"
99
+ return f"{error.__class__.__name__}: {detail}"
100
+ return str(error) or error.__class__.__name__
101
+
102
+
103
+ def publish_failure_diagnostic(
104
+ destination: str | Path,
105
+ *,
106
+ stage: str,
107
+ input_path: str | Path | None,
108
+ error: Exception,
109
+ tool_version: str,
110
+ run_id: str | None = None,
111
+ explicit_dates: dict[str, str] | None = None,
112
+ finding_code: str = "input_unreadable_or_invalid",
113
+ ) -> Path | None:
114
+ """Best-effort publication for failures that occur after CLI parsing.
115
+
116
+ The original failure remains authoritative: publication errors are contained so
117
+ callers can preserve the shared exit-code contract and report the first error.
118
+ """
119
+ path = Path(input_path) if input_path is not None else None
120
+ try:
121
+ input_hash = (
122
+ sha256_file(path)
123
+ if path is not None and path.is_file()
124
+ else sha256_bytes(canonical_json_bytes({"input_state": "unavailable"}))
125
+ )
126
+ message = _diagnostic_message(error)
127
+ finding: dict[str, Any] = {
128
+ "code": finding_code,
129
+ "severity": "error",
130
+ "message": message,
131
+ "exception_type": error.__class__.__name__,
132
+ }
133
+ qa_report = {
134
+ "schema_version": "1.0.0",
135
+ "stage": stage,
136
+ "run_outcome": "usage_or_internal_error",
137
+ "package_status": "draft",
138
+ "findings": [finding],
139
+ "warnings": [],
140
+ }
141
+ configuration_hash = sha256_bytes(
142
+ canonical_json_bytes(
143
+ {
144
+ "failure_contract": "1.0.0",
145
+ "stage": stage,
146
+ "explicit_dates": explicit_dates or {},
147
+ }
148
+ )
149
+ )
150
+ with RunPublisher(
151
+ destination,
152
+ stage=stage,
153
+ run_outcome="usage_or_internal_error",
154
+ package_status="draft",
155
+ input_hash=input_hash,
156
+ configuration_hash=configuration_hash,
157
+ schema_versions={"run_manifest": "1.0.0"},
158
+ tool_versions={"pi_steel": tool_version},
159
+ explicit_dates=explicit_dates or {},
160
+ warnings=[message],
161
+ approximations=[],
162
+ run_id=run_id,
163
+ ) as publisher:
164
+ publisher.write_qa_report(qa_report)
165
+ return publisher.publish()
166
+ except (ManifestError, OSError, TypeError, ValueError):
167
+ return None
@@ -0,0 +1,87 @@
1
+ """Canonical contract constants and deterministic identity helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ ESTIMATE_PACKAGE_VERSION = "1.0.0"
11
+ NEST_RESULT_VERSION = "1.0.0"
12
+ ITEM_INTENTS = frozenset(
13
+ {
14
+ "fabricated_part",
15
+ "purchased_stock",
16
+ "hardware",
17
+ "allowance",
18
+ "exclusion",
19
+ "by_others",
20
+ }
21
+ )
22
+
23
+
24
+ def canonical_json_bytes(value: Any) -> bytes:
25
+ return json.dumps(
26
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
27
+ ).encode("utf-8")
28
+
29
+
30
+ def content_hash(value: Any) -> str:
31
+ return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
32
+
33
+
34
+ def item_id_for(project_id: str, revision_id: str, source_id: str) -> str:
35
+ """Derive normalized identity from project and stable source identity.
36
+
37
+ ``revision_id`` remains in the signature for adapter compatibility. Legacy
38
+ source IDs already embed their revision when no stable source key exists;
39
+ explicit source IDs therefore remain stable across revisions.
40
+ """
41
+ digest = content_hash(
42
+ {
43
+ "project_id": project_id,
44
+ "source_id": source_id,
45
+ }
46
+ )
47
+ return f"item:{digest[:24]}"
48
+
49
+
50
+ def instance_ids(item_id: str, quantity: int) -> list[str]:
51
+ """Expand quantity predictably; increasing quantity never renames old instances."""
52
+ if quantity < 0:
53
+ raise ValueError("quantity must be non-negative")
54
+ return [f"{item_id}:instance:{index:04d}" for index in range(1, quantity + 1)]
55
+
56
+
57
+ def placement_ids(item_id: str, quantity: int) -> list[str]:
58
+ """Derive stable per-instance placement identities from canonical item order."""
59
+ if quantity < 0:
60
+ raise ValueError("quantity must be non-negative")
61
+ return [f"{item_id}:placement:{index:04d}" for index in range(1, quantity + 1)]
62
+
63
+
64
+ def fallback_source_id(revision_id: str, identity: Any) -> str:
65
+ """Create a revision-scoped legacy identity from stable row content."""
66
+ return f"legacy:{revision_id}:{content_hash(identity)[:20]}"
67
+
68
+
69
+ def estimate_input_projection(package: dict[str, Any]) -> dict[str, Any]:
70
+ """Return source/config semantics, excluding review and confirmation bookkeeping."""
71
+ projection = {
72
+ key: value
73
+ for key, value in package.items()
74
+ if key not in {"review"}
75
+ }
76
+ projection = json.loads(json.dumps(projection))
77
+ for stock in projection.get("stock", []):
78
+ stock.pop("reviewer_confirmation", None)
79
+ return projection
80
+
81
+
82
+ def estimate_input_hash(package: dict[str, Any]) -> str:
83
+ return content_hash(estimate_input_projection(package))
84
+
85
+
86
+ def finding_id_for(code: str, path: str) -> str:
87
+ return f"finding:{content_hash({'code': code, 'path': path})[:24]}"
@@ -0,0 +1,243 @@
1
+ """Geometry checks shared by contract validation and nesting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import defaultdict
7
+ from typing import Any
8
+
9
+
10
+ SUPPORTED_SHAPES = frozenset({"rect", "irregular"})
11
+
12
+
13
+ def finite_positive(value: Any) -> bool:
14
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value > 0
15
+
16
+
17
+ def hole_area(hole: dict[str, Any]) -> float:
18
+ if hole.get("kind") == "round":
19
+ diameter = hole.get("diameter", 0)
20
+ return math.pi * (diameter / 2) ** 2
21
+ if hole.get("kind") == "rect":
22
+ return hole.get("width", 0) * hole.get("height", 0)
23
+ return 0
24
+
25
+
26
+ def hole_within_bounds(hole: dict[str, Any], width: float, height: float) -> bool:
27
+ x, y = hole.get("x"), hole.get("y")
28
+ if not all(isinstance(value, (int, float)) and math.isfinite(value) for value in (x, y)):
29
+ return False
30
+ if hole.get("kind") == "round":
31
+ diameter = hole.get("diameter")
32
+ if not finite_positive(diameter):
33
+ return False
34
+ radius = diameter / 2
35
+ return radius <= x <= width - radius and radius <= y <= height - radius
36
+ if hole.get("kind") == "rect":
37
+ hole_width, hole_height = hole.get("width"), hole.get("height")
38
+ if not finite_positive(hole_width) or not finite_positive(hole_height):
39
+ return False
40
+ return (
41
+ hole_width / 2 <= x <= width - hole_width / 2
42
+ and hole_height / 2 <= y <= height - hole_height / 2
43
+ )
44
+ return False
45
+
46
+
47
+ def gross_area(geometry: dict[str, Any]) -> float:
48
+ if geometry.get("shape") == "irregular" and geometry.get("area") is not None:
49
+ return geometry["area"]
50
+ return geometry.get("width", 0) * geometry.get("height", 0)
51
+
52
+
53
+ def net_area(geometry: dict[str, Any]) -> float:
54
+ return gross_area(geometry) - sum(hole_area(hole) for hole in geometry.get("holes", []))
55
+
56
+
57
+ def plate_group_key(item: dict[str, Any]) -> tuple[Any, Any, Any]:
58
+ geometry = item.get("geometry", {})
59
+ return item.get("material"), item.get("grade"), geometry.get("thickness")
60
+
61
+
62
+ def group_plate_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
63
+ """Partition parts strictly by material, grade, and thickness."""
64
+ groups: dict[tuple[Any, Any, Any], list[dict[str, Any]]] = defaultdict(list)
65
+ for item in items:
66
+ groups[plate_group_key(item)].append(item)
67
+ return [
68
+ {
69
+ "material": key[0],
70
+ "grade": key[1],
71
+ "thickness": key[2],
72
+ "items": grouped,
73
+ }
74
+ for key, grouped in sorted(groups.items(), key=lambda pair: repr(pair[0]))
75
+ ]
76
+
77
+
78
+ def _finite_placement_values(placement: dict[str, Any]) -> bool:
79
+ return all(
80
+ isinstance(placement.get(field), (int, float))
81
+ and math.isfinite(placement[field])
82
+ for field in ("x", "y", "w", "h")
83
+ )
84
+
85
+
86
+ def _placements_separated(
87
+ first: dict[str, Any],
88
+ second: dict[str, Any],
89
+ clearance: float,
90
+ epsilon: float,
91
+ ) -> bool:
92
+ return (
93
+ first["x"] + first["w"] + clearance <= second["x"] + epsilon
94
+ or second["x"] + second["w"] + clearance <= first["x"] + epsilon
95
+ or first["y"] + first["h"] + clearance <= second["y"] + epsilon
96
+ or second["y"] + second["h"] + clearance <= first["y"] + epsilon
97
+ )
98
+
99
+
100
+ def _overlap_candidate_pairs(
101
+ placements: list[dict[str, Any]],
102
+ clearance: float,
103
+ epsilon: float,
104
+ ) -> list[tuple[int, int]]:
105
+ """Return deterministic overlap pairs without scanning every valid pair."""
106
+ numeric = {
107
+ index for index, placement in enumerate(placements)
108
+ if _finite_placement_values(placement)
109
+ }
110
+ sweepable = {
111
+ index for index in numeric
112
+ if placements[index]["w"] > 0 and placements[index]["h"] > 0
113
+ }
114
+ candidates: set[tuple[int, int]] = set()
115
+ active: list[int] = []
116
+ for current_index in sorted(
117
+ sweepable, key=lambda index: (placements[index]["x"], index)
118
+ ):
119
+ current = placements[current_index]
120
+ active = [
121
+ index
122
+ for index in active
123
+ if (
124
+ placements[index]["x"]
125
+ + placements[index]["w"]
126
+ + clearance
127
+ > current["x"] + epsilon
128
+ )
129
+ ]
130
+ for other_index in active:
131
+ other = placements[other_index]
132
+ if not (
133
+ other["y"] + other["h"] + clearance <= current["y"] + epsilon
134
+ or current["y"] + current["h"] + clearance
135
+ <= other["y"] + epsilon
136
+ ):
137
+ candidates.add(
138
+ (min(other_index, current_index), max(other_index, current_index))
139
+ )
140
+ active.append(current_index)
141
+
142
+ nonsweepable = numeric - sweepable
143
+ for first_index in sorted(nonsweepable):
144
+ for second_index in sorted(numeric):
145
+ if first_index < second_index:
146
+ candidates.add((first_index, second_index))
147
+ elif second_index < first_index and second_index in sweepable:
148
+ candidates.add((second_index, first_index))
149
+ return sorted(candidates)
150
+
151
+
152
+ def verify_nest_placements(
153
+ plate_reports: list[dict[str, Any]],
154
+ *,
155
+ edge_margin: float,
156
+ inter_part_clearance: float,
157
+ ) -> list[dict[str, Any]]:
158
+ """Verify normalized placements without relying on a packing algorithm's state."""
159
+ findings: list[dict[str, Any]] = []
160
+ epsilon = 1e-9
161
+ for plate_index, plate in enumerate(plate_reports):
162
+ usable_width = plate.get("W", 0) - 2 * edge_margin
163
+ usable_height = plate.get("H", 0) - 2 * edge_margin
164
+ placements = plate.get("placements", [])
165
+ plate_basis = (
166
+ plate.get("material"),
167
+ plate.get("grade"),
168
+ plate.get("thickness"),
169
+ )
170
+ for placement_index, placement in enumerate(placements):
171
+ path = f"$.plate_reports[{plate_index}].placements[{placement_index}]"
172
+ values = (
173
+ placement.get("x"),
174
+ placement.get("y"),
175
+ placement.get("w"),
176
+ placement.get("h"),
177
+ )
178
+ if not all(
179
+ isinstance(value, (int, float))
180
+ and not isinstance(value, bool)
181
+ and math.isfinite(value)
182
+ for value in values
183
+ ):
184
+ findings.append(
185
+ {
186
+ "code": "nonfinite_placement",
187
+ "path": path,
188
+ "message": "Placement coordinates and dimensions must be finite.",
189
+ }
190
+ )
191
+ continue
192
+ x, y, width, height = values
193
+ if (
194
+ width <= 0
195
+ or height <= 0
196
+ or x < -epsilon
197
+ or y < -epsilon
198
+ or x + width > usable_width + epsilon
199
+ or y + height > usable_height + epsilon
200
+ ):
201
+ findings.append(
202
+ {
203
+ "code": "placement_out_of_bounds",
204
+ "path": path,
205
+ "message": "Placement must remain inside the edge-margin boundary.",
206
+ }
207
+ )
208
+ placement_basis = (
209
+ placement.get("material"),
210
+ placement.get("grade"),
211
+ placement.get("thickness"),
212
+ )
213
+ if placement_basis != plate_basis:
214
+ findings.append(
215
+ {
216
+ "code": "material_mismatch",
217
+ "path": path,
218
+ "message": "Placement material, grade, and thickness must match its stock.",
219
+ }
220
+ )
221
+
222
+ for first_index, second_index in _overlap_candidate_pairs(
223
+ placements, inter_part_clearance, epsilon
224
+ ):
225
+ first = placements[first_index]
226
+ second = placements[second_index]
227
+ if not _placements_separated(
228
+ first, second, inter_part_clearance, epsilon
229
+ ):
230
+ findings.append(
231
+ {
232
+ "code": "placement_overlap",
233
+ "path": (
234
+ f"$.plate_reports[{plate_index}].placements"
235
+ f"[{first_index},{second_index}]"
236
+ ),
237
+ "message": (
238
+ "Placements overlap or violate the required "
239
+ "kerf-plus-gap clearance."
240
+ ),
241
+ }
242
+ )
243
+ return findings