mohan-bee/four-gang-wifi-switch-layout
A tscircuit package created by mohan-bee.
- Version
- 0.0.9
- License
- unset
- Stars
- 0
assembly-audit.ts
PCB
Schematic
import { readFile, writeFile, mkdir } from "node:fs/promises"
import { resolve } from "node:path"
import { createHash } from "node:crypto"
import { runAllPlacementChecks } from "@tscircuit/checks"
import { convertCircuitJsonToPickAndPlaceRows, convertCircuitJsonToPickAndPlaceCsv } from "circuit-json-to-pnp-csv"
import type { AnyCircuitElement } from "circuit-json"
const project=resolve(process.argv[2]??"")
const board=process.argv[3]
if(!board||!/^[a-zA-Z0-9_-]+$/.test(board))throw new Error("Usage: bun assembly-audit.ts PROJECT BOARD")
const raw=await readFile(`${project}/proof/${board}/circuit.json`)
const circuit=JSON.parse(raw.toString()) as AnyCircuitElement[]
const sources=new Map(circuit.flatMap(e=>e.type==="source_component"?[[e.source_component_id,e] as const]:[]))
const components=circuit.filter(e=>e.type==="pcb_component")
const pads=circuit.filter(e=>e.type==="pcb_smtpad"||e.type==="pcb_plated_hole")
const cadModels=circuit.filter(e=>e.type==="cad_component")
const issues:string[]=[]
const rows=components.map(component=>{
const source=sources.get(component.source_component_id)
if(!source)throw new Error(`Missing source component ${component.source_component_id}`)
const holes=pads.filter(p=>p.type==="pcb_plated_hole"&&p.pcb_component_id===component.pcb_component_id)
const smt=pads.filter(p=>p.type==="pcb_smtpad"&&p.pcb_component_id===component.pcb_component_id)
const ports=circuit.filter(e=>e.type==="pcb_port"&&e.pcb_component_id===component.pcb_component_id)
const sourcePorts=circuit.filter(e=>e.type==="source_port"&&e.source_component_id===source.source_component_id)
const isTht=holes.length>0,dnp=component.do_not_place??false
const label=source.name??source.source_component_id
if(isTht!==dnp)issues.push(`${label}: ${isTht?"THT must be factory DNP":"SMT-only component must be populated"}`)
if(component.layer!=="top")issues.push(`${label}: expected top-layer component placement`)
if(!holes.length&&!smt.length)issues.push(`${label}: physical pads/holes are missing`)
if(ports.length<sourcePorts.length)issues.push(`${label}: physical port count ${ports.length} is below source port count ${sourcePorts.length}`)
for(const port of ports)if(!pads.some(p=>p.pcb_component_id===component.pcb_component_id&&p.pcb_port_id===port.pcb_port_id))issues.push(`${label}: no physical pad/hole for ${port.pcb_port_id}`)
if(/^J/.test(label)&&source.ftype!=="simple_pin_header")issues.push(`${label}: connector is ${source.ftype}, expected ordinary pinheader`)
const lcsc=source.supplier_part_numbers?.jlcpcb?.[0]
if(!lcsc||!/^C\d+$/.test(lcsc))issues.push(`${label}: missing valid JLCPCB supplier ID`)
const model=cadModels.find(e=>e.pcb_component_id===component.pcb_component_id)
const footprint=model?.footprinter_string??(lcsc?`jlcpcb:${lcsc}`:source.ftype)
const value=source.manufacturer_part_number??("display_resistance" in source?source.display_resistance:undefined)??("display_capacitance" in source?source.display_capacitance:undefined)??source.ftype
return {label,source_component_id:source.source_component_id,pcb_component_id:component.pcb_component_id,ftype:source.ftype,layer:component.layer,is_tht:isTht,do_not_place:dnp,plated_holes:holes.length,smt_pads:smt.length,physical_ports:ports.length,source_ports:sourcePorts.length,lcsc,footprint,value}
})
const manual=rows.filter(r=>r.is_tht),assembled=rows.filter(r=>!r.is_tht)
// Factory DNP skips several native body/courtyard checks. Restore only the
// population flags in an audit copy to check the eventual hand-fitted assembly.
const allPopulated=structuredClone(circuit)
for(const element of allPopulated)if(element.type==="pcb_component")element.do_not_place=false
const placement=await runAllPlacementChecks(allPopulated)
const placementErrors=placement.filter(e=>e.type.includes("error"))
const placementWarnings=placement.filter(e=>!e.type.includes("error"))
const rotationWarnings:unknown[]=[]
const pnpOptions={supplier:"jlcpcb" as const,onRotationWarning:(warning:unknown)=>rotationWarnings.push(warning)}
const pnpRows=convertCircuitJsonToPickAndPlaceRows(circuit,pnpOptions)
const pnpNames=new Set(pnpRows.map(row=>row.designator))
if(pnpNames.size!==assembled.length||assembled.some(r=>!pnpNames.has(r.label))||manual.some(r=>pnpNames.has(r.label)))issues.push("Official pick-and-place output does not match the SMT-only assembly population")
const csv=(table:unknown[][])=>table.map(row=>row.map(v=>`"${String(v??"").replaceAll('"','""')}"`).join(",")).join("\n")+"\n"
const bomGroups=new Map<string,{refs:string[];value:string;footprint:string;lcsc:string}>()
for(const row of assembled){
const key=JSON.stringify([row.value,row.footprint,row.lcsc])
const group=bomGroups.get(key)??{refs:[],value:row.value,footprint:row.footprint,lcsc:row.lcsc??""}
group.refs.push(row.label);bomGroups.set(key,group)
}
const assemblyFolder=`${project}/assembly`
await mkdir(assemblyFolder,{recursive:true})
await writeFile(`${assemblyFolder}/${board}-smt-bom.csv`,csv([["Designator","Comment","Footprint","LCSC Part#"],...[...bomGroups.values()].map(group=>[group.refs.join(","),group.value,group.footprint,group.lcsc])]))
// Use the official converter for CPL formatting; collect angle warnings once above.
await writeFile(`${assemblyFolder}/${board}-smt-cpl.csv`,convertCircuitJsonToPickAndPlaceCsv(circuit,{supplier:"jlcpcb",onRotationWarning:()=>{}})+"\n")
await writeFile(`${assemblyFolder}/${board}-tht-manual-fit.csv`,csv([["Designator","Comment","Footprint","LCSC Part#","Mounting","Factory DNP","Factory Action","Final Assembly Action","source_component_id","pcb_component_id","Plated Hole Count"],...manual.map(row=>[row.label,row.value,row.footprint,row.lcsc,"THT","YES","DO NOT POPULATE","Manual fit after SMT assembly",row.source_component_id,row.pcb_component_id,row.plated_holes])]))
const report={
input_sha256:createHash("sha256").update(raw).digest("hex"),
valid:!issues.length&&!placementErrors.length,issues,
component_layers:[...new Set(rows.map(r=>r.layer))],
dnp_property:"pcb_component.do_not_place",all_tht_factory_dnp:manual.every(r=>r.do_not_place),
all_smt_populated:assembled.every(r=>!r.do_not_place),all_headers_are_plain_pinheaders:rows.filter(r=>/^J/.test(r.label)).every(r=>r.ftype==="simple_pin_header"),
tht_dnp_refs:manual.map(r=>r.label),smt_populated_refs:assembled.map(r=>r.label),
components:rows,physical_pad_and_port_retention_verified:!issues.some(i=>/physical pad|physical port/.test(i)),
eventual_hand_assembly_placement:{population_flags_cleared_only_in_audit_copy:true,errors:placementErrors,warnings:placementWarnings},
official_pnp_refs:[...pnpNames],cpl_supplier_rotation_verified:!rotationWarnings.length,cpl_rotation_warnings:rotationWarnings,
cpl_status:rotationWarnings.length?"Draft: supplier rotation verification remains pending":"Supplier rotations verified by available pin-1 metadata",
generated_files:[`${board}-smt-bom.csv`,`${board}-smt-cpl.csv`,`${board}-tht-manual-fit.csv`],
manufacturing_release:false,
}
await writeFile(`${project}/proof/${board}/assembly-audit.json`,JSON.stringify(report,null,2)+"\n")
console.log(JSON.stringify({board,valid:report.valid,tht_dnp:manual.map(r=>r.label),smt:assembled.map(r=>r.label),placement_errors:placementErrors.length,placement_warnings:placementWarnings.length,issues,cpl_rotation_warnings:rotationWarnings.length},null,2))
if(!report.valid)process.exitCode=1