astra/f1c100s

This code defines and renders a four-layer, top-mounted F1C100S module with detailed pin assignments, schematic annotations, and layout profiles, facilitating design, validation, and routing of the hardware including support for decoupling capacitors, crystal oscillators, and external connectors.

Version
0.11.0
License
unset
Stars
0

tests/module.test.tsx

import { joinSavedPathExits } from "../src/saved-paths";
import { checkPreviewPours } from "../scripts/check-preview-pours";
import { checkCapacitorOrientation } from "../scripts/check-capacitor-orientation";
import { checkPlatedExits } from "../scripts/check-plated-exits";
import { checkConventionalRouting } from "../scripts/check-conventions";
import { test, expect } from "bun:test";
import {
	Circuit,
	type GenericLocalAutorouter,
	type SimpleRouteJson,
	type SimplifiedPcbTrace,
} from "tscircuit";
import { F1C100SModule, LAYOUT_PROFILES, getF1C100SCircuitJson } from "../src";
import { SCHEMATIC_BANK_PINS } from "../src/schematic";
import { EXTERNAL_NETS, PIN_NETS } from "../src/pin-map";
import { makeLayout } from "../src/layout";
import { MODULE_SIZE, TERMINAL_EDGE } from "../src/profiles";
import { validateCircuit } from "../scripts/validate";

for (const profile of LAYOUT_PROFILES)
	test(`${profile}: stored copper loads and passes DRC`, async () => {
		const c = new Circuit();
		c.add(
			<board
				width={MODULE_SIZE}
				height={MODULE_SIZE}
				layers={4}
				minViaPadDiameter={0.45}
				minViaHoleDiameter={0.2}
				autorouter={{
					local: true,
					// Explicit parent nets now run the coincident-exit check, without solving copper.
					algorithmFn: joinSavedPathExits,
				}}
			>
				<F1C100SModule
					name="SOC"
					layoutProfile={profile}
					connections={Object.fromEntries(
						EXTERNAL_NETS.map((n) => [n, `net.${n}`]),
					)}
				/>
			</board>,
		);
		await c.renderUntilSettled();
		const json = c.getCircuitJson();
		// Check physical copper coverage independently of path orientation and
		// of shared power branches repeated in several pin-to-exit paths.
		const segments = (data: any[]) => {
			const names = new Map(
				data
					.filter((e) => e.type === "source_trace")
					.map((e) => [
						e.source_trace_id,
						/^D_C_D(\d+)_/.test(e.name ?? "")
							? `N_${PIN_NETS[Number(e.name!.match(/^D_C_D(\d+)_/)![1])]}`
							: e.name,
					]),
			);
			return data
				.filter((e) => e.type === "pcb_trace")
				.flatMap((t) =>
					t.route.flatMap((b: any, i: number) => {
						const a = t.route[i - 1];
						return a?.route_type === "wire" &&
							b.route_type === "wire" &&
							a.layer === b.layer &&
							Math.hypot(a.x - b.x, a.y - b.y) > 1e-6
							? [{ a, b, net: names.get(t.source_trace_id) }]
							: [];
					}),
				);
		};
		const stored = getF1C100SCircuitJson(profile);
		const loadedSegments = segments(json),
			storedSegments = segments(stored);
		for (const [from, to] of [
			[loadedSegments, storedSegments],
			[storedSegments, loadedSegments],
		]) {
			for (const s of from!)
				for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
					const x = s.a.x + (s.b.x - s.a.x) * fraction,
						y = s.a.y + (s.b.y - s.a.y) * fraction;
					expect(
						to!.some((t) => {
							if (t.net !== s.net || t.a.layer !== s.a.layer) return false;
							const dx = t.b.x - t.a.x,
								dy = t.b.y - t.a.y;
							const f = Math.max(
								0,
								Math.min(
									1,
									((x - t.a.x) * dx + (y - t.a.y) * dy) / (dx * dx + dy * dy),
								),
							);
							return Math.hypot(x - t.a.x - f * dx, y - t.a.y - f * dy) < 1e-5;
						}),
						`Copper coverage ${s.net} ${s.a.layer} ${x},${y}`,
					).toBe(true);
				}
		}
		const viaKeys = (data: any[]) =>
			data
				.filter((e) => e.type === "pcb_via")
				.map(
					(v) =>
						`${v.x.toFixed(6)},${v.y.toFixed(6)},${v.hole_diameter},${v.outer_diameter}`,
				)
				.sort();
		expect(viaKeys(json)).toEqual(viaKeys(stored));
		expect(
			json
				.filter((e) => e.type === "pcb_trace")
				.every((e: any) => e.pcb_trace_id.startsWith("saved_fanout_")),
		).toBe(true);

		const capacitors = json.filter(
			(e: any) =>
				e.type === "source_component" && e.ftype === "simple_capacitor",
		);
		expect(capacitors).toHaveLength(32);
		for (const capacitor of capacitors as any[]) {
			const placed = json.find(
				(e: any) =>
					e.type === "pcb_component" &&
					e.source_component_id === capacitor.source_component_id,
			) as any;
			const large =
				capacitor.name.startsWith("C_B_") ||
				["C_TV_VRN", "C_TV_VRP", "C_TV_REF"].includes(capacitor.name);
			expect(placed.layer).toBe(large ? "bottom" : "top");
			if (large) {
				expect(capacitor.manufacturer_part_number).toBe(
					capacitor.name === "C_B_VCC_DRAM"
						? "GRM219R71E105KA88D"
						: "GRM21BR61E106KA73L",
				);
				expect(capacitor.max_voltage_rating).toBe(25);
				expect(Math.max(placed.width, placed.height)).toBeGreaterThan(2.5);
			}
			const pads = json.filter(
				(e: any) =>
					e.type === "pcb_smtpad" &&
					e.pcb_component_id === placed.pcb_component_id,
			) as any[];
			expect(pads).toHaveLength(2);
			expect(pads.every((p) => p.layer === placed.layer)).toBe(true);
		}
		expect(checkConventionalRouting(json)).toEqual([]);
		expect(checkCapacitorOrientation(json)).toEqual([]);
		expect(checkPlatedExits(json)).toEqual([]);
		expect(
			json.filter(
				(e: any) =>
					e.type === "source_component" && e.ftype === "simple_resistor",
			),
		).toHaveLength(13);
		const crystals = json.filter(
			(e: any) => e.type === "source_component" && e.ftype === "simple_crystal",
		) as any[];
		expect(crystals).toHaveLength(1);
		expect(crystals[0].frequency).toBe(24e6);
		const sourcePorts = json.filter(
			(e: any) => e.type === "source_port",
		) as any[];
		const sources = json.filter(
			(e: any) => e.type === "source_component",
		) as any[];
		const netOf = (name: string, pin: number) => {
			const source = sources.find((s) => s.name === name);
			const port = sourcePorts.find(
				(p) =>
					p.source_component_id === source?.source_component_id &&
					p.pin_number === pin,
			);
			return (
				json.find(
					(e: any) =>
						e.type === "source_trace" &&
						e.connected_source_port_ids.includes(port?.source_port_id),
				) as any
			)?.name;
		};
		expect(netOf("Y1", 1)).toBe("N_HOSCI");
		expect(netOf("Y1", 3)).toBe("N_HOSCO");
		for (const pin of [2, 4]) expect(netOf("Y1", pin)).toBe("N_GND");
		for (const [name, net] of [
			["C_OSCI", "HOSCI"],
			["C_OSCO", "HOSCO"],
		]) {
			expect(
				sources.find((s) => s.name === name).manufacturer_part_number,
			).toBe("GRM1555C1H180JA01D");
			expect(sources.find((s) => s.name === name).max_voltage_rating).toBe(50);
			expect(netOf(name!, 1)).toBe(`N_${net}`);
			expect(netOf(name!, 2)).toBe("N_GND");
			expect(sources.find((s) => s.name === name).capacitance).toBeCloseTo(
				18e-12,
				15,
			);
		}
		expect(netOf("C_TV_REF", 1)).toBe("N_TV_VRP");
		expect(netOf("C_TV_REF", 2)).toBe("N_TV_VRN");
		for (const net of [
			"SDMMC0_CMD",
			"SDMMC0_D0",
			"SDMMC0_D1",
			"SDMMC0_D2",
			"SDMMC0_D3",
			"SPI0_CS",
			"TWI0_SDA",
			"TWI0_SCL",
		]) {
			expect(netOf(`R_PU_${net}`, 1)).toBe("N_VCC_IO");
			expect(netOf(`R_PU_${net}`, 2)).toBe(`N_${net}`);
			expect(sources.find((s) => s.name === `R_PU_${net}`).resistance).toBe(
				net.startsWith("SDMMC") ? 47000 : net === "SPI0_CS" ? 10000 : 4700,
			);
		}
		const chip = json.find(
			(e: any) => e.type === "source_component" && e.name === "U1",
		) as any;
		const physicalPorts = json.filter(
			(e: any) =>
				e.type === "source_port" &&
				e.source_component_id === chip.source_component_id,
		) as any[];
		const renderedPorts = json.filter(
			(e: any) => e.type === "schematic_port",
		) as any[];
		expect(physicalPorts).toHaveLength(89);
		for (const port of physicalPorts) {
			expect(
				renderedPorts.filter((p) => p.source_port_id === port.source_port_id),
			).toHaveLength(1);
		}
		expect(json.filter((e) => e.type === "schematic_component")).toHaveLength(
			53,
		);
		expect(json.filter((e) => e.type.endsWith("_error"))).toEqual([]);
		const errors = await validateCircuit(json);
		if (errors.length) console.log(errors.slice(0, 5));
		expect(errors).toEqual([]);
		expect(json.filter((e) => e.type === "pcb_trace").length).toBeGreaterThan(
			100,
		);
		expect(json.filter((e: any) => e.type === "pcb_autorouting_error")).toEqual(
			[],
		);
		expect(
			json.filter(
				(e: any) => e.type === "source_component" && /^C_D\d+$/.test(e.name),
			),
		).toHaveLength(15);
		if (profile !== "native") {
			const [, lcdSide, , storageSide] = profile.split("_");
			for (const [prefix, side] of [
				["LCD_", lcdSide],
				["SPI0_", storageSide],
				["SDMMC0_", storageSide],
			]) {
				const sources = json.filter(
					(e: any) =>
						e.type === "source_component" && e.name.startsWith(prefix),
				) as any[];
				expect(sources.length).toBe(
					prefix === "LCD_" ? 22 : prefix === "SPI0_" ? 4 : 6,
				);
				for (const source of sources) {
					const placed = json.find(
						(e: any) =>
							e.type === "pcb_component" &&
							e.source_component_id === source.source_component_id,
					) as any;
					const coord =
						side === "left" || side === "right"
							? placed.center.x
							: placed.center.y;
					expect(coord).toBeCloseTo(
						(side === "left" || side === "bottom" ? -1 : 1) * TERMINAL_EDGE,
						5,
					);
				}
			}
		}
	}, 120000);

for (const parentLayer of ["top", "inner1", "inner2", "bottom"] as const)
	test(`parent routing connects to a plated exit on ${parentLayer}`, async () => {
		const terminal = makeLayout("lcd_top_storage_right").terminals.find(
			(t) => t.name === "SPI0_CLK",
		)!;
		let captured: SimpleRouteJson | undefined;
		const factory = async (
			input: SimpleRouteJson,
		): Promise<GenericLocalAutorouter> => {
			captured = input;
			const handlers: Record<string, ((e: any) => void)[]> = {
				complete: [],
				progress: [],
				error: [],
			};
			const traces: SimplifiedPcbTrace[] = input.connections.map((c, i) => ({
				type: "pcb_trace",
				pcb_trace_id: `parent_${i}`,
				connection_name: c.name,
				route: c.pointsToConnect.map((p) => ({
					route_type: "wire",
					x: p.x,
					y: p.y,
					layer: parentLayer,
					width: 0.12,
				})),
			}));
			return {
				input,
				isRouting: false,
				on(e: string, f: (e: any) => void) {
					handlers[e]!.push(f);
				},
				start() {
					queueMicrotask(() =>
						handlers.complete!.forEach((f) => f({ type: "complete", traces })),
					);
				},
				stop() {},
				solveSync() {
					return traces;
				},
			};
		};
		const c = new Circuit();
		c.add(
			<board
				width={52}
				height={36}
				layers={4}
				autorouter={{ local: true, algorithmFn: factory }}
			>
				<F1C100SModule
					name="SOC"
					layoutProfile="lcd_top_storage_right"
					connections={{ SPI0_CLK: ".OUT > .pin1" }}
				/>
				<chip
					name="OUT"
					pinLabels={{ pin1: "pin1" }}
					pcbX={22}
					pcbY={terminal.y}
					footprint={
						<footprint>
							<platedhole
								portHints={["pin1"]}
								shape="circle"
								holeDiameter={0.2}
								outerDiameter={0.45}
								pcbX={0}
								pcbY={0}
							/>
						</footprint>
					}
				/>
			</board>,
		);
		await c.renderUntilSettled();
		expect(captured?.connections).toHaveLength(1);
		for (const p of captured!.connections[0]!.pointsToConnect) {
			expect(p.layers).toContain(parentLayer);
			const hole = captured!.obstacles.find(
				(o) =>
					o.circuitJsonMetadata?.pcb_plated_hole_id &&
					Math.hypot(o.center.x - p.x, o.center.y - p.y) < 1e-6,
			);
			expect(hole?.layers).toContain(parentLayer);
		}
		const exit = captured!.connections[0]!.pointsToConnect.find(
			(p) => Math.abs(p.x - TERMINAL_EDGE) < 1e-6,
		)!;
		expect(
			new Set(
				captured!.obstacles.find(
					(o) =>
						o.circuitJsonMetadata?.pcb_plated_hole_id &&
						Math.hypot(o.center.x - exit.x, o.center.y - exit.y) < 1e-6,
				)?.layers,
			),
		).toEqual(new Set(["top", "inner1", "inner2", "bottom"]));
		const xs = captured!.connections[0]!.pointsToConnect.map((p) => p.x).sort(
			(a, b) => a - b,
		);
		expect(xs).toEqual([TERMINAL_EDGE, 22]);
		const errors = await validateCircuit(c.getCircuitJson());
		if (errors.length) console.log(errors.slice(0, 5));
		expect(errors).toEqual([]);
	}, 120000);

test("the pin map covers every physical pin once", () => {
	expect([...SCHEMATIC_BANK_PINS].sort((a, b) => a - b)).toEqual(
		Array.from({ length: 89 }, (_, i) => i + 1),
	);
	expect(
		Object.keys(PIN_NETS)
			.map(Number)
			.sort((a, b) => a - b),
	).toEqual(Array.from({ length: 89 }, (_, i) => i + 1));
	expect(
		new Set(
			Array.from(
				{ length: 22 },
				(_, i) =>
					Object.values(PIN_NETS)
						.filter((n) => n.startsWith("LCD_"))
						.sort()[i],
			),
		).size,
	).toBe(22);
});

test("data is cloned; unsupported profiles and legacy props fail explicitly", () => {
	expect(() =>
		F1C100SModule({ name: "SOC", busProfile: "native" } as any),
	).toThrow("Use layoutProfile");
	const a = getF1C100SCircuitJson("native"),
		b = getF1C100SCircuitJson("native");
	a.splice(0);
	expect(b.length).toBeGreaterThan(100);
	expect(() => getF1C100SCircuitJson("typo" as any)).toThrow("Unsupported");
	expect(() =>
		F1C100SModule({ name: "SOC", variant: "native" } as any),
	).toThrow("Use layoutProfile");
	expect(() =>
		F1C100SModule({ name: "SOC", connections: { TYPO: "net.GND" } }),
	).toThrow("Unknown");
});

test("two rotated instances retain independent nets and copper", async () => {
	const c = new Circuit();
	c.add(
		<board width={82} height={42} layers={4}>
			<F1C100SModule
				name="A"
				connections={Object.fromEntries(
					EXTERNAL_NETS.map((n) => [n, `net.A_${n}`]),
				)}
				layoutProfile="lcd_top_storage_right"
				pcbX={-20}
			/>
			<F1C100SModule
				name="B"
				connections={Object.fromEntries(
					EXTERNAL_NETS.map((n) => [n, `net.B_${n}`]),
				)}
				layoutProfile="lcd_right_storage_left"
				pcbX={20}
				pcbRotation={90}
			/>
		</board>,
	);
	await c.renderUntilSettled();
	const json = c.getCircuitJson();
	expect(checkCapacitorOrientation(json)).toEqual([]);
	const errors = await validateCircuit(json);
	if (errors.length) console.log(errors.slice(0, 5));
	expect(errors).toEqual([]);
	expect(
		json.filter(
			(e: any) =>
				e.type === "source_component" &&
				e.manufacturer_part_number === "F1C100S",
		),
	).toHaveLength(2);
	const ids = json
		.filter((e: any) => e.type === "source_trace")
		.map((e: any) => e.source_trace_id);
	expect(new Set(ids).size).toBe(ids.length);
	const copperIds = json
		.filter((e: any) => e.type === "pcb_trace")
		.map((e: any) => e.pcb_trace_id);
	expect(new Set(copperIds).size).toBe(copperIds.length);
}, 120000);

test("exported schematic boxes share one processor across two A4 sheets", async () => {
	const { ProfilePreview } = await import("../src/ProfilePreview");
	const c = new Circuit();
	c.add(<ProfilePreview layoutProfile="lcd_top_storage_right" />);
	await c.renderUntilSettled();
	const json = c.getCircuitJson() as any[];
	expect(checkPreviewPours(json)).toEqual([]);
	expect(json.filter((e) => e.type.endsWith("_error"))).toEqual([]);
	expect(
		json.filter((e) => e.type === "schematic_element_outside_sheet_warning"),
	).toEqual([]);
	const sheets = json.filter((e) => e.type === "schematic_sheet");
	expect(sheets).toHaveLength(2);
	expect(sheets.every((e) => e.sheet_size === "a4")).toBe(true);
	const chips = json.filter(
		(e) =>
			e.type === "source_component" && e.manufacturer_part_number === "F1C100S",
	);
	expect(chips).toHaveLength(1);
	const chip = chips[0];
	const symbols = json.filter(
		(e) =>
			e.type === "schematic_component" &&
			e.source_component_id === chip.source_component_id,
	);
	expect(symbols).toHaveLength(7);
	expect(new Set(symbols.map((e) => e.schematic_sheet_id)).size).toBe(2);
	expect(symbols.every((e) => e.size.width < 3 && e.size.height < 9)).toBe(
		true,
	);
	const ports = json.filter(
		(e) =>
			e.type === "source_port" &&
			e.source_component_id === chip.source_component_id,
	);
	expect(ports).toHaveLength(89);
	for (const port of ports) {
		expect(
			json.filter(
				(e) =>
					e.type === "schematic_port" &&
					e.source_port_id === port.source_port_id,
			),
		).toHaveLength(1);
	}
	const sheetIds = new Set(sheets.map((e) => e.schematic_sheet_id));
	expect(json.filter((e) => e.type === "schematic_component")).toHaveLength(53);
	expect(
		json
			.filter((e) =>
				["schematic_component", "schematic_trace", "schematic_port"].includes(
					e.type,
				),
			)
			.every((e) => sheetIds.has(e.schematic_sheet_id)),
	).toBe(true);
	const errors = await validateCircuit(json);
	expect(errors).toEqual([]);
}, 120000);

test("angle audit rejects oblique runs including via approaches", () => {
	const route = (x: number, y: number, route_type = "wire") =>
		[
			{
				type: "pcb_trace",
				pcb_trace_id: "angle_fixture",
				route: [
					{ route_type: "wire", x: 0, y: 0, layer: "top" },
					{
						route_type,
						x,
						y,
						layer: "top",
						from_layer: "top",
						to_layer: "bottom",
					},
				],
			},
		] as any;
	for (const [x, y] of [
		[1, 0],
		[0, 1],
		[1, 1],
		[-1, 1],
		[0, 0],
	])
		expect(checkConventionalRouting(route(x!, y!))).toEqual([]);
	expect(checkConventionalRouting(route(1, 0.5))).toHaveLength(1);
	expect(checkConventionalRouting(route(1, 0.5, "via"))).toHaveLength(1);
});

test("saved external-net paths end at their named plated exit", async () => {
	for (const profile of LAYOUT_PROFILES) {
		const json = getF1C100SCircuitJson(profile) as any[];
		const paths = await Bun.file(
			new URL(`../src/generated/${profile}.trace-paths.json`, import.meta.url),
		).json();
		for (const path of paths) {
			const [name, pin] = path.connection.split(".pin");
			const component = json.find(
				(e) => e.type === "source_component" && e.name === name,
			);
			const port = json.find(
				(e) =>
					e.type === "source_port" &&
					e.source_component_id === component.source_component_id &&
					e.pin_number === Number(pin),
			);
			const net = json.find(
				(e) =>
					e.type === "source_trace" &&
					e.connected_source_port_ids.includes(port.source_port_id),
			);
			const terminal = makeLayout(profile).terminals.find(
				(t) => `N_${t.name}` === net.name,
			);
			if (!terminal) continue;
			expect(path.route.at(-1).x).toBeCloseTo(terminal.x, 6);
			expect(path.route.at(-1).y).toBeCloseTo(terminal.y, 6);
		}
	}
}, 30000);

test("orientation audit catches reversed and sideways decoupling pads", () => {
	const data: any[] = [
		{
			type: "source_component",
			source_component_id: "chip",
			source_group_id: "module",
			name: "U1",
			ftype: "simple_chip",
		},
		{
			type: "source_component",
			source_component_id: "cap",
			source_group_id: "module",
			name: "C_D5",
			ftype: "simple_capacitor",
		},
		{
			type: "source_port",
			source_port_id: "supply",
			source_component_id: "chip",
			pin_number: 5,
		},
		{
			type: "source_port",
			source_port_id: "positive",
			source_component_id: "cap",
			pin_number: 1,
		},
		{
			type: "source_port",
			source_port_id: "ground",
			source_component_id: "cap",
			pin_number: 2,
		},
		{ type: "source_trace", connected_source_port_ids: ["supply", "positive"] },
		{ type: "pcb_port", source_port_id: "supply", x: 0, y: 0 },
		{ type: "pcb_port", source_port_id: "positive", x: 1, y: 0 },
		{ type: "pcb_port", source_port_id: "ground", x: 2, y: 0 },
	];
	expect(checkCapacitorOrientation(data)).toEqual([]);
	data[7].x = 2;
	data[8].x = 1;
	expect(checkCapacitorOrientation(data)).toHaveLength(1);
	data[7].x = data[8].x = 1;
	data[7].y = 0.5;
	data[8].y = -0.5;
	expect(checkCapacitorOrientation(data)).toHaveLength(1);
});