diff --git a/lib/components/primitive-components/DifferentialPair.ts b/lib/components/primitive-components/DifferentialPair.ts index 72a06edb2..608c3b37c 100644 --- a/lib/components/primitive-components/DifferentialPair.ts +++ b/lib/components/primitive-components/DifferentialPair.ts @@ -3,6 +3,7 @@ import { type BaseComponentConfig, PrimitiveComponent, } from "../base-components/PrimitiveComponent" +import { DifferentialPair_doInitialSourceDesignRuleChecks } from "./DifferentialPair_doInitialSourceDesignRuleChecks" /** * Declares the routing constraints for a positive and negative trace pair. @@ -16,4 +17,8 @@ export class DifferentialPair extends PrimitiveComponent< zodProps: differentialPairProps, } } + + doInitialSourceDesignRuleChecks(): void { + DifferentialPair_doInitialSourceDesignRuleChecks(this) + } } diff --git a/lib/components/primitive-components/DifferentialPair_doInitialSourceDesignRuleChecks.ts b/lib/components/primitive-components/DifferentialPair_doInitialSourceDesignRuleChecks.ts new file mode 100644 index 000000000..fbb896234 --- /dev/null +++ b/lib/components/primitive-components/DifferentialPair_doInitialSourceDesignRuleChecks.ts @@ -0,0 +1,194 @@ +import type { SourcePort } from "circuit-json" +import type { DifferentialPair } from "./DifferentialPair" +import type { Port } from "./Port/Port" + +type ConnectionPolarity = "positive" | "negative" +type SourceComponentId = NonNullable + +type ResolvedPointToPointConnection = { + sourcePorts: SourcePort[] + sourceNetName?: string +} + +const resolvePointToPointConnection = ( + differentialPair: DifferentialPair, + connectionSelector: string, +): ResolvedPointToPointConnection | undefined => { + const { db } = differentialPair.root! + const subcircuit = differentialPair.getSubcircuit() + const subcircuitSourceTraces = db.source_trace + .list() + .filter( + (sourceTrace) => sourceTrace.subcircuit_id === subcircuit.subcircuit_id, + ) + const matchingSourceTraces = subcircuitSourceTraces.filter( + (sourceTrace) => sourceTrace.name === connectionSelector, + ) + const connectivityMapKeys = new Set() + + if (matchingSourceTraces.length > 0) { + for (const sourceTrace of matchingSourceTraces) { + if (sourceTrace.subcircuit_connectivity_map_key) { + connectivityMapKeys.add(sourceTrace.subcircuit_connectivity_map_key) + } + } + } else { + const selectedPort = subcircuit.selectOne(connectionSelector, { + type: "port", + }) + if (selectedPort?.source_port_id) { + const sourcePort = db.source_port.get(selectedPort.source_port_id) + if (sourcePort?.subcircuit_connectivity_map_key) { + connectivityMapKeys.add(sourcePort.subcircuit_connectivity_map_key) + } + } + } + + if (connectivityMapKeys.size !== 1) return undefined + const connectivityMapKey = connectivityMapKeys.values().next().value + if (!connectivityMapKey) return undefined + + const sourcePorts = db.source_port + .list() + .filter( + (sourcePort) => + sourcePort.subcircuit_connectivity_map_key === connectivityMapKey, + ) + const sourceNet = db.source_net + .list() + .find( + (sourceNet) => + sourceNet.subcircuit_connectivity_map_key === connectivityMapKey, + ) + + return { + sourcePorts, + sourceNetName: sourceNet?.name, + } +} + +const formatTerminalPinList = (terminalPinSelectors: string[]): string => { + if (terminalPinSelectors.length <= 1) return terminalPinSelectors.join("") + if (terminalPinSelectors.length === 2) { + return `${terminalPinSelectors[0]} and ${terminalPinSelectors[1]}` + } + return `${terminalPinSelectors.slice(0, -1).join(", ")}, and ${terminalPinSelectors.at(-1)}` +} + +const getTerminalPinSelector = ( + sourcePort: SourcePort, + sourceComponentsById: Map, +): string => { + let sourceComponent: { name: string } | undefined + if (sourcePort.source_component_id) { + sourceComponent = sourceComponentsById.get(sourcePort.source_component_id) + } + if (!sourceComponent?.name) return sourcePort.source_port_id + + const portName = + sourcePort.most_frequently_referenced_by_name ?? sourcePort.name + return `.${sourceComponent.name} > .${portName}` +} + +const getPointToPointWarningMessage = ({ + differentialPairName, + connectionPolarity, + connectionSelector, + sourceNetName, + terminalPinSelectors, +}: { + differentialPairName: string + connectionPolarity: ConnectionPolarity + connectionSelector: string + sourceNetName?: string + terminalPinSelectors: string[] +}): string => { + let resolvedConnectionName = `"${connectionSelector}"` + if (sourceNetName) { + resolvedConnectionName = `net.${sourceNetName}` + } + const terminalCount = terminalPinSelectors.length + let pinOrPins = "pins" + if (terminalCount === 1) { + pinOrPins = "pin" + } + const terminalList = formatTerminalPinList(terminalPinSelectors) + const suggestedSelector = terminalPinSelectors[0] + let correction = "Connect exactly two terminal pins" + if (terminalCount > 2) { + correction = "Remove the extra connection" + } + let selectorRecommendation = "" + if (suggestedSelector) { + selectorRecommendation = ` and prefer a pin selector such as ${connectionPolarity}Connection="${suggestedSelector}"` + } + let terminalListDescription = "" + if (terminalList) { + terminalListDescription = `: ${terminalList}` + } + + return ( + `Differential pair "${differentialPairName}" ${connectionPolarity}Connection resolves to ${resolvedConnectionName}, which is not point-to-point. ` + + `It connects to ${terminalCount} ${pinOrPins}${terminalListDescription}. ` + + `${correction}${selectorRecommendation}.` + ) +} + +export const DifferentialPair_doInitialSourceDesignRuleChecks = ( + differentialPair: DifferentialPair, +): void => { + const { db } = differentialPair.root! + + const sourceComponentsById = new Map() + for (const sourceComponent of db.source_component.list()) { + sourceComponentsById.set(sourceComponent.source_component_id, { + name: sourceComponent.name, + }) + } + + for (const connectionPolarity of ["positive", "negative"] as const) { + let connectionSelector = differentialPair._parsedProps.negativeConnection + if (connectionPolarity === "positive") { + connectionSelector = differentialPair._parsedProps.positiveConnection + } + + const resolvedConnection = resolvePointToPointConnection( + differentialPair, + connectionSelector, + ) + if (!resolvedConnection) continue + + const terminalSourcePorts = resolvedConnection.sourcePorts + if (terminalSourcePorts.length === 2) continue + let warningSourceComponentId = "" + const firstTerminalSourcePort = terminalSourcePorts[0] + if (firstTerminalSourcePort) { + if (!firstTerminalSourcePort.source_component_id) { + throw new Error( + `Differential pair "${differentialPair.name}" resolved terminal port "${firstTerminalSourcePort.source_port_id}" without a source_component_id`, + ) + } + warningSourceComponentId = firstTerminalSourcePort.source_component_id + } + + const terminalPinSelectors = terminalSourcePorts + .map((sourcePort) => + getTerminalPinSelector(sourcePort, sourceComponentsById), + ) + .sort((selectorA, selectorB) => selectorA.localeCompare(selectorB)) + db.source_property_ignored_warning.insert({ + source_component_id: warningSourceComponentId, + property_name: `${connectionPolarity}Connection`, + error_type: "source_property_ignored_warning", + message: getPointToPointWarningMessage({ + differentialPairName: differentialPair.name, + connectionPolarity, + connectionSelector, + sourceNetName: resolvedConnection.sourceNetName, + terminalPinSelectors, + }), + subcircuit_id: + differentialPair.getSubcircuit().subcircuit_id ?? undefined, + }) + } +} diff --git a/tests/components/primitive-components/differential-pair/failure-tests/port-selector-multiple-traces-warning.test.tsx b/tests/components/primitive-components/differential-pair/failure-tests/port-selector-multiple-traces-warning.test.tsx new file mode 100644 index 000000000..6bdc92b3b --- /dev/null +++ b/tests/components/primitive-components/differential-pair/failure-tests/port-selector-multiple-traces-warning.test.tsx @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("stores a warning when a differential pair port selector matches multiple traces", (): void => { + const { circuit } = getTestFixture() + + circuit.add( + + + + + + + + + + + , + ) + + circuit.render() + + const pointToPointWarning = + circuit.db.source_property_ignored_warning.getWhere({ + property_name: "positiveConnection", + }) + expect(pointToPointWarning).toMatchObject({ + type: "source_property_ignored_warning", + error_type: "source_property_ignored_warning", + property_name: "positiveConnection", + }) + expect(pointToPointWarning?.message).toContain( + 'positiveConnection resolves to ".R1 > .pin1", which is not point-to-point', + ) +}) diff --git a/tests/components/primitive-components/differential-pair/missing-terminal-source-component-id.test.tsx b/tests/components/primitive-components/differential-pair/missing-terminal-source-component-id.test.tsx new file mode 100644 index 000000000..a5e33d9a5 --- /dev/null +++ b/tests/components/primitive-components/differential-pair/missing-terminal-source-component-id.test.tsx @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test" +import type { DifferentialPair } from "lib/components/primitive-components/DifferentialPair" +import { DifferentialPair_doInitialSourceDesignRuleChecks } from "lib/components/primitive-components/DifferentialPair_doInitialSourceDesignRuleChecks" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("throws when a differential-pair terminal has no source component ID", (): void => { + const { circuit } = getTestFixture() + + circuit.add( + + + + + + + , + ) + + circuit.render() + + const j1 = circuit.db.source_component.getWhere({ name: "J1" })! + const j1Pin1 = circuit.db.source_port + .list() + .find( + (sourcePort) => + sourcePort.source_component_id === j1.source_component_id && + sourcePort.name === "pin1", + )! + circuit.db.source_port.update(j1Pin1.source_port_id, { + source_component_id: null as never, + }) + + const differentialPair = circuit.selectOne( + "differentialpair", + ) as DifferentialPair + expect(() => + DifferentialPair_doInitialSourceDesignRuleChecks(differentialPair), + ).toThrow( + `Differential pair "USB_DATA" resolved terminal port "${j1Pin1.source_port_id}" without a source_component_id`, + ) +}) diff --git a/tests/components/primitive-components/differential-pair/net-fragments-point-to-point.test.tsx b/tests/components/primitive-components/differential-pair/net-fragments-point-to-point.test.tsx new file mode 100644 index 000000000..c35b9713b --- /dev/null +++ b/tests/components/primitive-components/differential-pair/net-fragments-point-to-point.test.tsx @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("does not warn for a two-terminal conductor split through a source net", (): void => { + const { circuit } = getTestFixture() + + circuit.add( + + + + + + + + , + ) + + circuit.render() + + expect( + circuit.db.source_property_ignored_warning + .list() + .filter( + (warning) => + warning.property_name === "positiveConnection" || + warning.property_name === "negativeConnection", + ), + ).toEqual([]) +}) diff --git a/tests/components/primitive-components/differential-pair/point-to-point-warning-circuit-json.test.tsx b/tests/components/primitive-components/differential-pair/point-to-point-warning-circuit-json.test.tsx new file mode 100644 index 000000000..e556a8113 --- /dev/null +++ b/tests/components/primitive-components/differential-pair/point-to-point-warning-circuit-json.test.tsx @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("stores a property warning for a branched differential pair", (): void => { + const { circuit } = getTestFixture() + + circuit.add( + + + + + + + + + + , + ) + + circuit.render() + + const pointToPointWarnings = circuit.db.source_property_ignored_warning.list() + expect(pointToPointWarnings).toHaveLength(1) + expect(pointToPointWarnings[0]).toMatchObject({ + type: "source_property_ignored_warning", + error_type: "source_property_ignored_warning", + property_name: "positiveConnection", + }) + expect(pointToPointWarnings[0]?.source_component_id).toBeDefined() + expect(pointToPointWarnings[0]?.message).toBe( + 'Differential pair "USB_DATA" positiveConnection resolves to net.DP, which is not point-to-point. It connects to 3 pins: .J1 > .pin1, .TP1 > .pin1, and .U1 > .pin1. Remove the extra connection and prefer a pin selector such as positiveConnection=".J1 > .pin1".', + ) +}) diff --git a/tests/components/primitive-components/differential-pair/zero-terminal-point-to-point-warning.test.tsx b/tests/components/primitive-components/differential-pair/zero-terminal-point-to-point-warning.test.tsx new file mode 100644 index 000000000..b2e260018 --- /dev/null +++ b/tests/components/primitive-components/differential-pair/zero-terminal-point-to-point-warning.test.tsx @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("stores a warning for a differential-pair connection with no terminal pins", (): void => { + const { circuit } = getTestFixture() + + circuit.add( + + + + + + + , + ) + + circuit.render() + + const warning = circuit.db.source_property_ignored_warning.getWhere({ + property_name: "positiveConnection", + }) + expect(warning).toMatchObject({ + type: "source_property_ignored_warning", + source_component_id: "", + error_type: "source_property_ignored_warning", + property_name: "positiveConnection", + }) + expect(warning?.message).toBe( + 'Differential pair "USB_DATA" positiveConnection resolves to net.DP, which is not point-to-point. It connects to 0 pins. Connect exactly two terminal pins.', + ) +})