imrishabh18/rp2040-motor-controller

This circuit integrates a Raspberry Pi RP2040 microcontroller with multiple power management components, USB interface, drivers, sensors (including an AS5600 encoder), protection elements, and various passive and active components to facilitate device control, measurement, and communication.

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