From 7306fc9c5856720e5aa2765c99631ec4bad257bc Mon Sep 17 00:00:00 2001 From: AouIssa Date: Sat, 7 Feb 2026 13:13:01 +0800 Subject: [PATCH] Add specialized linear layout for decoupling capacitor partitions (#15) --- .../SingleInnerPartitionPackingSolver.ts | 46 +++++ package.json | 1 + .../LinearDecouplingCapLayout.test.ts | 195 ++++++++++++++++++ ...apLayout-decoupling-cap-partition.snap.svg | 44 ++++ ...ingCapLayout-full-pipeline-layout.snap.svg | 44 ++++ 5 files changed, 330 insertions(+) create mode 100644 tests/PackInnerPartitionsSolver/LinearDecouplingCapLayout.test.ts create mode 100644 tests/PackInnerPartitionsSolver/__snapshots__/LinearDecouplingCapLayout-decoupling-cap-partition.snap.svg create mode 100644 tests/PackInnerPartitionsSolver/__snapshots__/LinearDecouplingCapLayout-full-pipeline-layout.snap.svg diff --git a/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts b/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts index 88db103..a22f0a7 100644 --- a/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts +++ b/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts @@ -38,6 +38,13 @@ export class SingleInnerPartitionPackingSolver extends BaseSolver { } override _step() { + // For decoupling cap partitions, use a linear row layout instead of PackSolver2 + if (this.partitionInputProblem.partitionType === "decoupling_caps") { + this.layout = this.createLinearDecouplingCapLayout() + this.solved = true + return + } + // Initialize PackSolver2 if not already created if (!this.activeSubSolver) { const packInput = this.createPackInput() @@ -64,6 +71,45 @@ export class SingleInnerPartitionPackingSolver extends BaseSolver { } } + /** + * Arranges decoupling capacitors in a horizontal row centered at the origin. + * Chips are sorted by chipId for deterministic ordering and spaced evenly + * using decouplingCapsGap (or chipGap as fallback). + */ + private createLinearDecouplingCapLayout(): OutputLayout { + const chips = Object.entries(this.partitionInputProblem.chipMap).sort( + ([a], [b]) => a.localeCompare(b), + ) + + const gap = + this.partitionInputProblem.decouplingCapsGap ?? + this.partitionInputProblem.chipGap + + // Calculate total width of the row + const totalChipWidth = chips.reduce((sum, [, chip]) => sum + chip.size.x, 0) + const totalGapWidth = Math.max(0, chips.length - 1) * gap + const totalWidth = totalChipWidth + totalGapWidth + + // Place chips left-to-right, centered around x=0 + const chipPlacements: Record = {} + let currentX = -totalWidth / 2 + + for (const [chipId, chip] of chips) { + currentX += chip.size.x / 2 + chipPlacements[chipId] = { + x: currentX, + y: 0, + ccwRotationDegrees: 0, + } + currentX += chip.size.x / 2 + gap + } + + return { + chipPlacements, + groupPlacements: {}, + } + } + private createPackInput(): PackInput { // Fall back to filtered mapping (weak + strong) const pinToNetworkMap = createFilteredNetworkMapping({ diff --git a/package.json b/package.json index 1fd506e..ab35eed 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@tscircuit/schematic-viewer": "^2.0.26", "@types/bun": "latest", "bpc-graph": "^0.0.66", + "bun-match-svg": "^0.0.15", "calculate-packing": "^0.0.31", "circuit-json": "^0.0.226", "graphics-debug": "^0.0.64", diff --git a/tests/PackInnerPartitionsSolver/LinearDecouplingCapLayout.test.ts b/tests/PackInnerPartitionsSolver/LinearDecouplingCapLayout.test.ts new file mode 100644 index 0000000..b967672 --- /dev/null +++ b/tests/PackInnerPartitionsSolver/LinearDecouplingCapLayout.test.ts @@ -0,0 +1,195 @@ +import { test, expect } from "bun:test" +import "bun-match-svg" +import { getSvgFromGraphicsObject } from "graphics-debug" +import { LayoutPipelineSolver } from "lib/solvers/LayoutPipelineSolver/LayoutPipelineSolver" +import { IdentifyDecouplingCapsSolver } from "lib/solvers/IdentifyDecouplingCapsSolver/IdentifyDecouplingCapsSolver" +import { ChipPartitionsSolver } from "lib/solvers/ChipPartitionsSolver/ChipPartitionsSolver" +import { SingleInnerPartitionPackingSolver } from "lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver" +import { getPinIdToStronglyConnectedPinsObj } from "lib/solvers/LayoutPipelineSolver/getPinIdToStronglyConnectedPinsObj" +import { getExampleCircuitJson } from "../assets/RP2040Circuit" +import { getInputProblemFromCircuitJsonSchematic } from "lib/testing/getInputProblemFromCircuitJsonSchematic" +import type { PartitionInputProblem } from "lib/types/InputProblem" + +/** + * Build the RP2040 problem with decoupling cap detection enabled. + * Restricts capacitor rotations to [0] and sets net flags for + * ground/voltage identification. + */ +function createRP2040Problem() { + const circuitJson = getExampleCircuitJson() + const problem = getInputProblemFromCircuitJsonSchematic(circuitJson, { + useReadableIds: true, + }) + + // Restrict capacitor rotations to [0] (vertical only) + for (const [chipId, chip] of Object.entries(problem.chipMap)) { + if (/^C\d+$/.test(chipId)) { + chip.availableRotations = [0] + } + } + + // Mark ground and positive voltage source nets + if (problem.netMap["GND"]) { + problem.netMap["GND"].isGround = true + } + if (problem.netMap["V3_3"]) { + problem.netMap["V3_3"].isPositiveVoltageSource = true + } + if (problem.netMap["V1_1"]) { + problem.netMap["V1_1"].isPositiveVoltageSource = true + } + + // Propagate net membership through strong connections so the + // IdentifyDecouplingCapsSolver can find net pairs for decoupling caps. + // When a cap pin is strongly connected to a chip pin that has a net + // connection, the cap pin should also be on that net. + for (const [connKey, connected] of Object.entries(problem.pinStrongConnMap)) { + if (!connected) continue + const [pinA, pinB] = connKey.split("-") + if (!pinA || !pinB) continue + + // Find nets for each pin + for (const [netKey, netConnected] of Object.entries(problem.netConnMap)) { + if (!netConnected) continue + const [netPin, netId] = netKey.split("-") + if (!netPin || !netId) continue + + // If pinA has a net, propagate to pinB + if (netPin === pinA && !problem.netConnMap[`${pinB}-${netId}`]) { + problem.netConnMap[`${pinB}-${netId}`] = true + } + // If pinB has a net, propagate to pinA + if (netPin === pinB && !problem.netConnMap[`${pinA}-${netId}`]) { + problem.netConnMap[`${pinA}-${netId}`] = true + } + } + } + + problem.decouplingCapsGap = 0.2 + problem.partitionGap = 1.2 + + return problem +} + +test("Decoupling caps are arranged in a linear row via full pipeline", () => { + const problem = createRP2040Problem() + const solver = new LayoutPipelineSolver(problem) + solver.solve() + + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + + const outputLayout = solver.getOutputLayout() + + // Dynamically find decoupling cap partitions + const decapPartitions = solver.chipPartitionsSolver!.partitions.filter( + (p) => (p as PartitionInputProblem).partitionType === "decoupling_caps", + ) + expect(decapPartitions.length).toBeGreaterThan(0) + + for (const partition of decapPartitions) { + const capChipIds = Object.keys(partition.chipMap) + expect(capChipIds.length).toBeGreaterThanOrEqual(2) + + // All caps in this partition should share the same Y coordinate + const placements = capChipIds.map((id) => outputLayout.chipPlacements[id]!) + const yValues = placements.map((p) => p.y) + const firstY = yValues[0]! + for (const y of yValues) { + expect(y).toBeCloseTo(firstY, 6) + } + + // Sort by X to check spacing + const sorted = capChipIds + .map((id) => ({ + id, + placement: outputLayout.chipPlacements[id]!, + chip: partition.chipMap[id]!, + })) + .sort((a, b) => a.placement.x - b.placement.x) + + // Check no overlaps and consistent gap + const expectedGap = problem.decouplingCapsGap ?? problem.chipGap + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]! + const curr = sorted[i]! + const edgeDistance = + curr.placement.x - + curr.chip.size.x / 2 - + (prev.placement.x + prev.chip.size.x / 2) + expect(edgeDistance).toBeCloseTo(expectedGap, 4) + } + } + + // Generate SVG snapshot of the full pipeline result + const viz = solver.visualize() + const svg = getSvgFromGraphicsObject(viz, { includeTextLabels: true }) + expect(svg).toMatchSvgSnapshot(import.meta.path, "full-pipeline-layout") +}) + +test("SingleInnerPartitionPackingSolver arranges decoupling caps linearly", () => { + const problem = createRP2040Problem() + + // Run identification and partitioning to get a decoupling_caps partition + const decapSolver = new IdentifyDecouplingCapsSolver(problem) + decapSolver.solve() + expect(decapSolver.solved).toBe(true) + expect(decapSolver.outputDecouplingCapGroups.length).toBeGreaterThan(0) + + const partitionSolver = new ChipPartitionsSolver({ + inputProblem: problem, + decouplingCapGroups: decapSolver.outputDecouplingCapGroups, + }) + partitionSolver.solve() + expect(partitionSolver.solved).toBe(true) + + const decapPartition = partitionSolver.partitions.find( + (p) => (p as PartitionInputProblem).partitionType === "decoupling_caps", + ) as PartitionInputProblem + expect(decapPartition).toBeDefined() + + const pinIdToStronglyConnectedPins = + getPinIdToStronglyConnectedPinsObj(problem) + + // Solve just this partition + const packSolver = new SingleInnerPartitionPackingSolver({ + partitionInputProblem: decapPartition, + pinIdToStronglyConnectedPins, + }) + packSolver.solve() + + expect(packSolver.solved).toBe(true) + expect(packSolver.layout).not.toBeNull() + + const layout = packSolver.layout! + const chipIds = Object.keys(decapPartition.chipMap) + + // All Y coordinates should be 0 (centered horizontal row) + for (const chipId of chipIds) { + const placement = layout.chipPlacements[chipId]! + expect(placement.y).toBe(0) + expect(placement.ccwRotationDegrees).toBe(0) + } + + // Check linear arrangement: sorted by X, evenly spaced + const sorted = chipIds + .map((id) => ({ + id, + x: layout.chipPlacements[id]!.x, + width: decapPartition.chipMap[id]!.size.x, + })) + .sort((a, b) => a.x - b.x) + + const expectedGap = decapPartition.decouplingCapsGap ?? decapPartition.chipGap + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]! + const curr = sorted[i]! + const edgeDistance = curr.x - curr.width / 2 - (prev.x + prev.width / 2) + expect(edgeDistance).toBeCloseTo(expectedGap, 4) + } + + // Generate SVG snapshot of the partition layout + const viz = packSolver.visualize() + const svg = getSvgFromGraphicsObject(viz, { includeTextLabels: true }) + expect(svg).toMatchSvgSnapshot(import.meta.path, "decoupling-cap-partition") +}) diff --git a/tests/PackInnerPartitionsSolver/__snapshots__/LinearDecouplingCapLayout-decoupling-cap-partition.snap.svg b/tests/PackInnerPartitionsSolver/__snapshots__/LinearDecouplingCapLayout-decoupling-cap-partition.snap.svg new file mode 100644 index 0000000..bba60a3 --- /dev/null +++ b/tests/PackInnerPartitionsSolver/__snapshots__/LinearDecouplingCapLayout-decoupling-cap-partition.snap.svg @@ -0,0 +1,44 @@ +C12.1 (V3_3)C12.2 (GND)C14.1 (V3_3)C14.2 (GND)C8.1 (V3_3)C8.2 (GND)C13.1 (V3_3)C13.2 (GND)C15.1 (V3_3)C15.2 (GND)C19.1 (V3_3)C19.2 (GND)C12C14C8C13C15C19C12C14C8C13C15C19 \ No newline at end of file diff --git a/tests/PackInnerPartitionsSolver/__snapshots__/LinearDecouplingCapLayout-full-pipeline-layout.snap.svg b/tests/PackInnerPartitionsSolver/__snapshots__/LinearDecouplingCapLayout-full-pipeline-layout.snap.svg new file mode 100644 index 0000000..b7e1373 --- /dev/null +++ b/tests/PackInnerPartitionsSolver/__snapshots__/LinearDecouplingCapLayout-full-pipeline-layout.snap.svg @@ -0,0 +1,44 @@ +C12.1 (V3_3)C12.2 (GND)C14.1 (V3_3)C14.2 (GND)C8.1 (V3_3)C8.2 (GND)C13.1 (V3_3)C13.2 (GND)C15.1 (V3_3)C15.2 (GND)C19.1 (V3_3)C19.2 (GND)C18.1 (V1_1)C18.2 (GND)C7.1 (V1_1)C7.2 (GND)U3.1 (V3_3)U3.2U3.3U3.4U3.5U3.6U3.7U3.8U3.9U3.10 (V3_3)U3.11U3.12U3.13U3.14U3.15U3.16U3.17U3.18U3.19U3.20U3.21U3.22 (V3_3)U3.23 (V1_1)U3.24U3.25U3.26U3.27U3.28U3.29U3.30U3.31U3.32U3.33 (V3_3)U3.34U3.35U3.36U3.37U3.38U3.39U3.40U3.41U3.42 (V3_3)U3.43U3.44U3.45U3.46 (USB_N)U3.47 (USB_P)U3.48 (USB_VDD)U3.49 (V3_3)U3.50 (V1_1)U3.51U3.52U3.53U3.54U3.55U3.56U3.57C11.1C11.2 (GND)C10.1C10.2 (GND)C9.1 (V1_1)C9.2 (GND)C12C14C8C13C15C19C18C7U3C11C10C9C12C14C8C13C15C19C18C7U3C11C10C9 \ No newline at end of file