shibosoftwaredev/f1c100s-linux-nema8-stepper-controller
Restores component part numbers and CAD models, removes test-point 3D bodies, and validates that solder paste exists only on top-side SMT pads without altering copper or DRC.
- Version
- 0.3.5
- License
- unset
- Stars
- 0
scripts/export-assembly.py
#!/usr/bin/env python3
"""Create a self-consistent Gerber, drill, BOM and CPL fabrication package."""
import csv
import hashlib
import io
import json
from pathlib import Path
import subprocess
import zipfile
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist" / "index"
FAB = ROOT / "fabrication"
REPORTS = ROOT / "reports"
CIRCUIT_PATH = DIST / "circuit.json"
ARCHIVE = FAB / "board-gerbers.zip"
def normalize_csv(data: bytes):
text = data.decode().replace("\\r\\n", "\n").replace("\r\n", "\n")
rows = list(csv.DictReader(io.StringIO(text)))
assert rows, "empty assembly CSV"
out = io.StringIO(newline="")
writer = csv.DictWriter(out, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
return out.getvalue().encode(), rows
FAB.mkdir(exist_ok=True)
REPORTS.mkdir(exist_ok=True)
# tsci resolves output paths relative to the input circuit JSON directory.
result = subprocess.run(
["npx", "tsci", "export", "dist/index/circuit.json", "--format", "gerbers",
"--output", "../../fabrication/board-gerbers.zip"],
cwd=ROOT, text=True, capture_output=True,
)
if result.returncode:
raise SystemExit(result.stdout + result.stderr)
circuit_bytes = CIRCUIT_PATH.read_bytes()
circuit = json.loads(circuit_bytes)
sources = {item["source_component_id"]: item for item in circuit
if item["type"] == "source_component"}
placed = {sources[item["source_component_id"]]["name"]: item for item in circuit
if item["type"] == "pcb_component" and item["source_component_id"] in sources}
expected = {name for name, item in placed.items()
if sources[item["source_component_id"]].get("ftype") != "simple_test_point"
and not item.get("do_not_place", False)}
excluded = set(placed) - expected
with zipfile.ZipFile(ARCHIVE) as archive:
entries = {name: archive.read(name) for name in archive.namelist()}
bom_data, bom_rows = normalize_csv(entries["bom.csv"])
pnp_data, pnp_rows = normalize_csv(entries["pick_and_place.csv"])
bom_designators = {row["Designator"] for row in bom_rows}
pnp_designators = {row["Designator"] for row in pnp_rows}
assert bom_designators == expected, f"BOM mismatch: missing={expected - bom_designators}, extra={bom_designators - expected}"
assert pnp_designators == expected, f"CPL mismatch: missing={expected - pnp_designators}, extra={pnp_designators - expected}"
assert all(row["Layer"] == "top" for row in pnp_rows), "bottom assembly part found"
assert all(row["JLCPCB Part #"].startswith("C") for row in bom_rows), "missing JLCPCB part number"
entries["bom.csv"] = bom_data
entries["pick_and_place.csv"] = pnp_data
with zipfile.ZipFile(ARCHIVE, "w", zipfile.ZIP_DEFLATED) as archive:
for name, data in entries.items():
archive.writestr(name, data)
for name, data in entries.items():
if name.endswith((".gbr", ".drl")):
(FAB / name).write_bytes(data)
(FAB / "assembly-bom.csv").write_bytes(bom_data)
(FAB / "assembly-pick-and-place.csv").write_bytes(pnp_data)
vias = [item for item in circuit if item["type"] == "pcb_via"]
errors = [item for item in circuit if item["type"].endswith("_error")]
stock = json.loads((REPORTS / "jlcpcb-stock-audit.json").read_text())
circuit_sha = hashlib.sha256(circuit_bytes).hexdigest()
assert not errors
assert all(via["hole_diameter"] == 0.3 and via["outer_diameter"] == 0.45 for via in vias)
assert stock["circuitSha256"] == circuit_sha
assert all(part["status"] == "in-stock" for part in stock["parts"])
audit = {
"circuitJsonSha256": circuit_sha,
"gerbersSha256": hashlib.sha256(ARCHIVE.read_bytes()).hexdigest(),
"drcErrors": len(errors),
"assemblyComponents": len(expected),
"assemblyTopCount": len(pnp_rows),
"assemblyBottomComponents": [],
"excludedBareTestFeatures": sorted(excluded),
"pcbVias": len(vias),
"viaHoleDiameterMm": 0.3,
"viaPadDiameterMm": 0.45,
"jlcpcbUniqueParts": stock["uniquePartCount"],
"jlcpcbStockIssues": sum(part["status"] != "in-stock" for part in stock["parts"]),
"generatedFiles": sorted(name for name in entries if name.endswith((".gbr", ".drl"))),
"assemblyNote": "Review polarity and rotation in the JLCPCB placement preview before payment; this is required for every PCBA order.",
}
(REPORTS / "fabrication-audit.json").write_text(json.dumps(audit, indent=2) + "\n")
print(json.dumps(audit, indent=2))
