imrishabh18/rp2040-motor-controller
This circuit incorporates a USB-C Power Delivery (PD) interface with a CH224K controller, a WJ500V-5.08-04P motor output connector, multiple power conditioning components including capacitors, resistors, diodes, and an RP2040 microcontroller, to manage motor control, signal routing, and power management for a USB-powered stepper motor system.
- Version
- 1.0.14
- License
- unset
- Stars
- 0
tests/board.test.tsx
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import {
checkPadTraceClearance,
checkSourceTracesMatchPcbTraceThickness,
runAllRoutingChecks,
} from "@tscircuit/checks";
import type { CircuitJson, PcbSmtPad, PcbTrace, PcbVia, SourceTrace } from "circuit-json";
import { Circuit } from "tscircuit";
import Rp2040MotorController from "../index.circuit";
import { routedViaOverlapsPad } from "./helpers/routedViaOverlapsPad";
import { assertCommonGround } from "./helpers/assertCommonGround";
test("renders the complete RP2040 stepper-motor controller", async () => {
let circuitJson: CircuitJson;
if (process.env.CIRCUIT_JSON_PATH) {
circuitJson = JSON.parse(readFileSync(process.env.CIRCUIT_JSON_PATH, "utf8"));
} else {
const circuit = new Circuit();
circuit.add(<Rp2040MotorController routingDisabled={false} />);
await circuit.renderUntilSettled();
circuitJson = circuit.getCircuitJson();
}
assertCommonGround(circuitJson);
const crystalTraceNames = ["XIN", "XOUT_R", "XOUT", "CXIN", "CXOUT"];
const crystalSourceTraces = circuitJson.filter(
(element): element is SourceTrace =>
element.type === "source_trace" &&
crystalTraceNames.includes(element.name ?? ""),
);
const crystalPcbTraces = circuitJson.filter(
(element): element is PcbTrace =>
element.type === "pcb_trace" &&
crystalSourceTraces.some(
(sourceTrace) =>
sourceTrace.source_trace_id === element.source_trace_id,
),
);
const sourceComponents = circuitJson.filter(
(element) => element.type === "source_component",
);
const pcbBoard = circuitJson.find((element) => element.type === "pcb_board");
const componentsMissingJlcpcbPartNumbers = sourceComponents
.filter(
(component) =>
component.ftype !== "simple_test_point" &&
!component.supplier_part_numbers?.jlcpcb?.length,
)
.map((component) => component.name)
.sort();
const schematicSheets = circuitJson
.filter((element) => element.type === "schematic_sheet")
.sort((a, b) => (a.sheet_index ?? 0) - (b.sheet_index ?? 0));
const schematicComponents = circuitJson.filter(
(element) => element.type === "schematic_component",
);
const schematicOutsideSheetWarnings = circuitJson.filter(
(element) =>
String(element.type) === "schematic_element_outside_sheet_warning",
);
const schematicSheetIds = new Set(
schematicSheets.map((sheet) => sheet.schematic_sheet_id),
);
const schematicText = new Set(
circuitJson.flatMap((element) =>
element.type === "schematic_text" ? [element.text] : [],
),
);
expect(
sourceComponents.some(
(component) => component.manufacturer_part_number === "RP2040",
),
).toBe(true);
expect(
pcbBoard?.type === "pcb_board"
? { width: pcbBoard.width, height: pcbBoard.height }
: undefined,
).toEqual({ width: 50, height: 49 });
expect(pcbBoard?.num_layers).toBe(2);
expect(pcbBoard?.min_via_hole_diameter).toBe(0.3);
expect(pcbBoard?.min_via_pad_diameter).toBe(0.45);
expect(
crystalSourceTraces
.map((trace) => ({ name: trace.name, maxViaCount: trace.max_via_count }))
.sort((a, b) => a.name!.localeCompare(b.name!)),
).toEqual(
crystalTraceNames
.map((name) => ({ name, maxViaCount: 0 }))
.sort((a, b) => a.name.localeCompare(b.name)),
);
expect(crystalPcbTraces.length).toBe(crystalTraceNames.length);
expect(
crystalPcbTraces.flatMap((trace) =>
trace.route.filter((point) => point.route_type === "via"),
),
).toEqual([]);
expect(
sourceComponents.some(
(component) => component.manufacturer_part_number === "DRV8847PWPR",
),
).toBe(true);
expect(
sourceComponents.some(
(component) => component.manufacturer_part_number === "CH224K",
),
).toBe(true);
expect(
sourceComponents.some(
(component) =>
component.manufacturer_part_number === "TYPE_C_16PIN_2MD_073_",
),
).toBe(true);
const stepperConnector = sourceComponents.find(
(component) => component.name === "J1",
);
expect(stepperConnector?.manufacturer_part_number).toBe(
"WJ500V-5.08-04P-14-00A",
);
expect(stepperConnector?.supplier_part_numbers?.jlcpcb).toEqual([
"C42377749",
]);
const stepperCadComponent = circuitJson.find(
(element) =>
element.type === "cad_component" &&
element.source_component_id === stepperConnector?.source_component_id,
);
expect(
stepperCadComponent?.type === "cad_component"
? stepperCadComponent.rotation?.z
: undefined,
).toBe(270);
expect(
circuitJson
.flatMap((element) =>
element.type === "source_port" &&
element.source_component_id === stepperConnector?.source_component_id &&
typeof element.pin_number === "number"
? [element.pin_number]
: [],
)
.sort((a, b) => a - b),
).toEqual([1, 2, 3, 4]);
expect(sourceComponents.some((component) => component.name === "J2")).toBe(
false,
);
expect(componentsMissingJlcpcbPartNumbers).toEqual([]);
expect(
circuitJson
.flatMap((element) => {
if (
element.type !== "source_trace" ||
!/^MOTOR_[AB]_(PLUS|MINUS)$/.test(element.name ?? "")
) {
return [];
}
return [{ name: element.name, displayName: element.display_name }];
})
.sort((a, b) => a.name!.localeCompare(b.name!)),
).toEqual([
{
name: "MOTOR_A_MINUS",
displayName: ".DRIVER > .AOUT2 to .J1 > .pin2",
},
{
name: "MOTOR_A_PLUS",
displayName: ".DRIVER > .AOUT1 to .J1 > .pin1",
},
{
name: "MOTOR_B_MINUS",
displayName: ".DRIVER > .BOUT2 to .J1 > .pin4",
},
{
name: "MOTOR_B_PLUS",
displayName: ".DRIVER > .BOUT1 to .J1 > .pin3",
},
]);
expect(
schematicSheets.map((sheet) => ({
name: sheet.name,
displayName: (sheet as typeof sheet & { display_name?: string })
.display_name,
sheetIndex: sheet.sheet_index,
})),
).toEqual([
{
name: "controller",
displayName: "RP2040 Controller",
sheetIndex: 1,
},
{
name: "controller_programming",
displayName: "Programming USB-C & QSPI",
sheetIndex: 2,
},
{
name: "motor_driver",
displayName: "Stepper Motor Driver",
sheetIndex: 3,
},
{
name: "motor_power",
displayName: "USB-C PD Motor Power",
sheetIndex: 4,
},
]);
expect(
schematicComponents.every(
(component) =>
Boolean(component.schematic_sheet_id) &&
schematicSheetIds.has(component.schematic_sheet_id!),
),
).toBe(true);
expect(schematicComponents.length).toBeGreaterThan(0);
expect(schematicOutsideSheetWarnings).toEqual([]);
expect([...schematicText]).toEqual(
expect.arrayContaining([
"RP2040 & Power",
"USB-C, QSPI Flash & 3.3V Power",
"Clock",
"Status & SWD Debug",
"H-Bridge, Power & Control",
"Stepper Motor Output",
"USB-C PD Negotiation",
"Power Filtering",
]),
);
const unexpectedErrors = circuitJson.filter((element) =>
element.type.endsWith("_error"),
);
// Length limits from the reusable RP2040 module are reported as warnings by
// the latest checker. Keep fabrication-blocking routing errors as the gate.
const routingIssues = (
await runAllRoutingChecks(structuredClone(circuitJson))
).filter((element) => element.type.endsWith("_error"));
const widthWarnings = checkSourceTracesMatchPcbTraceThickness(circuitJson);
const powerSourceTraceIds = new Set<string>(
circuitJson.flatMap((element) =>
element.type === "source_trace" &&
(element.min_trace_thickness ?? 0) >= 0.5
? [element.source_trace_id!]
: [],
),
);
const powerPcbTraceIds = new Set(
circuitJson.flatMap((element) =>
element.type === "pcb_trace" &&
Boolean(element.source_trace_id) &&
powerSourceTraceIds.has(element.source_trace_id!)
? [element.pcb_trace_id]
: [],
),
);
const preferredPowerPadClearanceIssues = checkPadTraceClearance(circuitJson, {
minClearance: 0.15,
}).filter((issue) => powerPcbTraceIds.has(issue.pcb_trace_id));
const uniqueUnderWidthTraceIds = new Set(
widthWarnings.map((warning) => warning.pcb_trace_id),
);
const copperPours = circuitJson.filter(
(element) => element.type === "pcb_copper_pour",
);
const pcbTraceIds = new Set(
circuitJson.flatMap((element) =>
element.type === "pcb_trace" ? [element.pcb_trace_id] : [],
),
);
const routedVias = circuitJson.filter(
(element): element is PcbVia =>
element.type === "pcb_via" &&
Boolean(element.pcb_trace_id) &&
pcbTraceIds.has(element.pcb_trace_id!),
);
expect(routedVias.length).toBeGreaterThan(0);
for (const via of routedVias) {
expect(via.hole_diameter).toBe(0.3);
expect(via.outer_diameter).toBe(0.45);
expect(new Set(via.layers)).toEqual(new Set(["top", "bottom"]));
}
const consecutiveViaRuns = circuitJson.flatMap((element) => {
if (element.type !== "pcb_trace") return [];
const runs: number[] = [];
let runLength = 0;
for (const point of element.route) {
if (point.route_type === "via") {
runLength++;
} else {
if (runLength > 1) runs.push(runLength);
runLength = 0;
}
}
if (runLength > 1) runs.push(runLength);
return runs;
});
const smtPads = circuitJson.filter(
(element): element is PcbSmtPad => element.type === "pcb_smtpad",
);
const traceEndpointPorts = new Map<string, Set<string>>();
for (const element of circuitJson) {
if (element.type !== "pcb_trace") continue;
const endpointPorts = new Set<string>();
for (const point of element.route) {
if (point.route_type !== "wire") continue;
if (point.start_pcb_port_id) endpointPorts.add(point.start_pcb_port_id);
if (point.end_pcb_port_id) endpointPorts.add(point.end_pcb_port_id);
}
traceEndpointPorts.set(element.pcb_trace_id, endpointPorts);
}
const routedViaPadOverlaps = routedVias.flatMap((via) =>
smtPads
// An escape via may intentionally merge with the terminal pad belonging
// to that same trace. Only contact with a foreign pad is a DRC problem.
.filter(
(pad) =>
routedViaOverlapsPad(via, pad) &&
!traceEndpointPorts.get(via.pcb_trace_id!)?.has(pad.pcb_port_id!),
)
.map((pad) => ({
pcb_via_id: via.pcb_via_id,
pcb_trace_id: via.pcb_trace_id,
pcb_smtpad_id: pad.pcb_smtpad_id,
})),
);
const copperPourNetIds = new Set(
copperPours.map((pour) => pour.source_net_id),
);
const copperPourNet = circuitJson.find(
(element) =>
element.type === "source_net" &&
copperPourNetIds.has(element.source_net_id),
);
const upperMotorASourceTrace = circuitJson.find(
(element) =>
element.type === "source_trace" &&
element.display_name === ".DRIVER > .AOUT2 to .J1 > .pin2",
);
const upperMotorASourceTraceId =
upperMotorASourceTrace?.type === "source_trace"
? upperMotorASourceTrace.source_trace_id
: undefined;
const upperMotorAPcbTrace = circuitJson
.filter(
(element): element is PcbTrace =>
element.type === "pcb_trace" &&
element.source_trace_id === upperMotorASourceTraceId,
)
.sort((a, b) => b.route.length - a.route.length)[0];
const rIsenBSourceTrace = circuitJson.find(
(element) =>
element.type === "source_trace" &&
element.name === "ISEN_B",
);
const rIsenBSourceTraceId =
rIsenBSourceTrace?.type === "source_trace"
? rIsenBSourceTrace.source_trace_id
: undefined;
const rIsenBPcbTrace = circuitJson.find(
(element) =>
element.type === "pcb_trace" &&
(element.source_trace_id === rIsenBSourceTraceId || "connection_name" in element && element.connection_name === rIsenBSourceTraceId),
);
let upperMotorALength = 0;
let upperMotorANominalLength = 0;
let upperMotorAWidthArea = 0;
let upperMotorABottomLength = 0;
let upperMotorALongestUnderNominalRun = 0;
let upperMotorACurrentUnderNominalRun = 0;
let upperMotorAMinWidth = Number.POSITIVE_INFINITY;
const compactMotorPreferredWidth = 0.85;
if (upperMotorAPcbTrace?.type === "pcb_trace") {
for (let index = 0; index < upperMotorAPcbTrace.route.length - 1; index++) {
const start = upperMotorAPcbTrace.route[index];
const end = upperMotorAPcbTrace.route[index + 1];
if (
start?.route_type !== "wire" ||
end?.route_type !== "wire" ||
start.layer !== end.layer
) {
upperMotorACurrentUnderNominalRun = 0;
continue;
}
const segmentLength = Math.hypot(end.x - start.x, end.y - start.y);
const conservativeWidth = Math.min(start.width, end.width);
upperMotorAMinWidth = Math.min(upperMotorAMinWidth, conservativeWidth);
upperMotorALength += segmentLength;
upperMotorAWidthArea += segmentLength * conservativeWidth;
if (conservativeWidth >= compactMotorPreferredWidth - 1e-6) {
upperMotorANominalLength += segmentLength;
upperMotorACurrentUnderNominalRun = 0;
} else {
upperMotorACurrentUnderNominalRun += segmentLength;
upperMotorALongestUnderNominalRun = Math.max(
upperMotorALongestUnderNominalRun,
upperMotorACurrentUnderNominalRun,
);
}
if (start.layer === "bottom") upperMotorABottomLength += segmentLength;
}
}
expect(unexpectedErrors).toEqual([]);
expect(routingIssues).toEqual([]);
expect(routedViaPadOverlaps).toEqual([]);
// The connectivity router can represent one shared ground transition in two
// MST branches. The no-stitching invariant is that a route never contains a
// run of inserted vias; coincident same-net records render as one transition.
// A normal layer transition has one via. Multiple consecutive via route
// points indicate that a post-routing via-stitching pass modified the route.
expect(consecutiveViaRuns).toEqual([]);
// The board's hard rule is 0.1 mm. Pipeline 7's integrated power-trace
// expansion targets half of each power trace's nominal width; the remaining
// 0.15 mm misses are dense DRV8847 and USB-C package escapes.
expect(preferredPowerPadClearanceIssues.length).toBeLessThanOrEqual(20);
expect(
preferredPowerPadClearanceIssues.every(
(issue) => (issue.actual_clearance ?? 0) >= 0.1 - 1e-9,
),
).toBe(true);
expect(copperPours.length).toBeGreaterThanOrEqual(2);
expect(new Set(copperPours.map((pour) => pour.layer))).toEqual(
new Set(["top", "bottom"]),
);
expect(copperPours.some((pour) => pour.layer === "top")).toBe(true);
expect(copperPours.some((pour) => pour.layer === "bottom")).toBe(true);
expect(copperPourNetIds.size).toBe(1);
expect(copperPourNet?.type === "source_net" && copperPourNet.name).toBe(
"GND",
);
expect(
copperPours.every(
(pour) =>
pour.shape === "brep" &&
pour.brep_shape.outer_ring.vertices.length >= 3 &&
pour.covered_with_solder_mask,
),
).toBe(true);
expect(upperMotorASourceTrace?.type).toBe("source_trace");
expect(upperMotorAPcbTrace?.type).toBe("pcb_trace");
const upperMotorAVias =
upperMotorAPcbTrace?.type === "pcb_trace"
? upperMotorAPcbTrace.route.filter((point) => point.route_type === "via")
: [];
expect(upperMotorAVias.length).toBeLessThanOrEqual(2);
if (upperMotorAVias.length > 0) {
// The edge-mounted four-pin terminal only needs a short bottom-layer jog.
// Keep enough length to prove the via performs a real layer transition
// rather than forming a coincident or stitched-via artifact.
expect(upperMotorABottomLength).toBeGreaterThan(2);
} else {
expect(upperMotorABottomLength).toBe(0);
}
// The integrated expander may use a 0.15 mm neck at the fine-pitch DRV8847 pad,
// then widens the motor trace toward 1 mm. The compact layout permits a
// short 0.8 mm corridor while retaining the same conservative average-width
// and escape-length requirements.
expect(upperMotorAMinWidth).toBeGreaterThanOrEqual(0.15);
expect(upperMotorALongestUnderNominalRun).toBeLessThanOrEqual(5);
expect(upperMotorANominalLength / upperMotorALength).toBeGreaterThan(0.75);
expect(upperMotorAWidthArea / upperMotorALength).toBeGreaterThan(0.8);
expect(rIsenBSourceTrace?.type).toBe("source_trace");
expect(rIsenBPcbTrace?.type).toBe("pcb_trace");
const rIsenBVias =
rIsenBPcbTrace?.type === "pcb_trace"
? rIsenBPcbTrace.route.filter((point) => point.route_type === "via")
: [];
// Pipeline 7 may briefly use the bottom layer to escape this dense driver
// area. Bound the detour to one down/up via pair.
expect(rIsenBVias.length).toBeLessThanOrEqual(2);
// This check reports the minimum route-point width for an entire connected
// net, so it is a board-integration regression budget rather than a
// length-weighted quality metric. Keep Pipeline 7's current escape budget
// bounded while the route-specific assertions above guard copper quality.
expect(widthWarnings.length).toBeLessThanOrEqual(47);
expect(uniqueUnderWidthTraceIds.size).toBeLessThanOrEqual(22);
}, 600_000);