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))