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