diff --git a/lib/components/normal-components/Chip.ts b/lib/components/normal-components/Chip.ts index fa9972406..77723afba 100644 --- a/lib/components/normal-components/Chip.ts +++ b/lib/components/normal-components/Chip.ts @@ -1,10 +1,9 @@ import { chipProps } from "@tscircuit/props" import { pcb_component_invalid_layer_error } from "circuit-json" import { NormalComponent } from "lib/components/base-components/NormalComponent" -import { type SchematicBoxDimensions } from "lib/utils/schematic/getAllDimensionsForSchematicBox" -import { Trace } from "lib/components/primitive-components/Trace/Trace" import { Port } from "lib/components/primitive-components/Port" -import type { z } from "zod" +import { Trace } from "lib/components/primitive-components/Trace/Trace" +import { type SchematicBoxDimensions } from "lib/utils/schematic/getAllDimensionsForSchematicBox" export class Chip extends NormalComponent< typeof chipProps, @@ -12,10 +11,6 @@ export class Chip extends NormalComponent< > { schematicBoxDimensions: SchematicBoxDimensions | null = null - constructor(props: z.input) { - super(props) - } - get config() { return { componentName: "Chip", @@ -85,13 +80,15 @@ export class Chip extends NormalComponent< const { db } = this.root! const { _parsedProps: props } = this - const source_component = db.source_component.insert({ - ftype: "simple_chip", + const sourceComponentInput = { + ftype: "simple_chip" as const, name: this.name, manufacturer_part_number: props.manufacturerPartNumber, supplier_part_numbers: props.supplierPartNumbers, display_name: props.displayName, - }) + is_simulation_boundary: props.simulationBoundary, + } + const source_component = db.source_component.insert(sourceComponentInput) this.source_component_id = source_component.source_component_id! } diff --git a/lib/components/primitive-components/AnalogSimulation.ts b/lib/components/primitive-components/AnalogSimulation.ts index 86fd7679d..e2a7e15a5 100644 --- a/lib/components/primitive-components/AnalogSimulation.ts +++ b/lib/components/primitive-components/AnalogSimulation.ts @@ -15,18 +15,27 @@ export class AnalogSimulation extends PrimitiveComponent< doInitialSimulationRender(): void { const { db } = this.root! - const { duration, name, startTime, timePerStep, spiceOptions } = - this._parsedProps + const { + duration, + name, + simulationType, + startTime, + timePerStep, + timeout, + spiceOptions, + } = this._parsedProps - const durationMs = duration || 10 // ms - const timePerStepMs = timePerStep || 0.01 // ms + const isTransient = simulationType === "spice_transient_analysis" + const durationMs = isTransient ? duration || 10 : undefined + const timePerStepMs = isTransient ? timePerStep || 0.01 : undefined const simulationExperiment = db.simulation_experiment.insert({ - name: name ?? "spice_transient_analysis", - experiment_type: "spice_transient_analysis" as const, + name: name ?? simulationType, + experiment_type: simulationType, end_time_ms: durationMs, - start_time_ms: startTime, + start_time_ms: isTransient ? startTime : undefined, time_per_step: timePerStepMs, + timeout_ms: timeout, spice_options: spiceOptions, }) diff --git a/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts b/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts index 7c8243434..f35cd626e 100644 --- a/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts +++ b/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts @@ -1,5 +1,5 @@ -import { SpiceNetlist, circuitJsonToSpice } from "circuit-json-to-spice" import type { AnyCircuitElement, SimulationCurrentProbe } from "circuit-json" +import { SpiceNetlist, circuitJsonToSpice } from "circuit-json-to-spice" import Debug from "debug" import { getTransientVoltageGraphNamesFromSpiceNetlist } from "lib/utils/simulation/get-transient-voltage-graph-names-from-spice-netlist" import { resetSimulationColorState } from "lib/utils/simulation/getSimulationColorForId" @@ -8,13 +8,16 @@ import type { Ammeter } from "../../normal-components/Ammeter" import type { AnalogSimulation } from "../AnalogSimulation" import type { VoltageProbe } from "../VoltageProbe" import type { GraphDisplayOverrides } from "./GraphDisplayOverrides" +import type { Group } from "./Group" +import type { InsertedSimulationGraph } from "./InsertedSimulationGraph" import { getAmmeterGraphDisplayOverrides, getVoltageProbeGraphDisplayOverrides, } from "./getGraphDisplayOverrides" -import type { InsertedSimulationGraph } from "./InsertedSimulationGraph" -import type { Group } from "./Group" +import { insertSimulationExperimentError } from "./insert-simulation-experiment-error" import { insertIndependentAxisScopeTraces } from "./insertIndependentAxisScopeTraces" +import { isOperatingPointCurrent } from "./is-operating-point-current" +import { isOperatingPointVoltage } from "./is-operating-point-voltage" import { isCircuitElementInput } from "./isCircuitElementInput" import { isCurrentGraph } from "./isCurrentGraph" import { isVoltageGraph } from "./isVoltageGraph" @@ -64,7 +67,10 @@ const getCircuitJsonForAnalogSimulation = ({ if ( element.type === "simulation_transient_voltage_graph" || element.type === "simulation_transient_current_graph" || - element.type === "simulation_unknown_experiment_error" + element.type === "simulation_operating_point_voltage" || + element.type === "simulation_operating_point_current" || + element.type === "simulation_unknown_experiment_error" || + element.type === "simulation_experiment_error" ) { return false } @@ -125,10 +131,10 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { debug(`Generated SPICE string:\n${spiceString}`) } catch (error) { debug(`Failed to convert circuit JSON to SPICE: ${error}`) - root.db.simulation_unknown_experiment_error.insert({ - simulation_experiment_id: simulationExperimentId, - error_type: "simulation_unknown_experiment_error", - message: error instanceof Error ? error.message : String(error), + insertSimulationExperimentError({ + db: root.db, + simulationExperimentId, + error, }) continue } @@ -210,11 +216,32 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { const spiceEngine = spiceEngineMap[engineName] if (!spiceEngine) { - throw new Error( - `SPICE engine "${engineName}" not found in platform config. Available engines: ${JSON.stringify( - Object.keys(spiceEngineMap).filter((k) => k !== "spicey"), - )}`, - ) + insertSimulationExperimentError({ + db: root.db, + simulationExperimentId, + error: new Error( + `SPICE engine "${engineName}" not found in platform config. Available engines: ${JSON.stringify( + Object.keys(spiceEngineMap).filter((k) => k !== "spicey"), + )}`, + ), + }) + continue + } + + if ( + engineName === "spicey" && + simulationExperiment.experiment_type === "spice_dc_operating_point" + ) { + insertSimulationExperimentError({ + db: root.db, + simulationExperimentId, + error: { + code: "unsupported_analysis", + message: + 'The built-in "spicey" engine does not support DC operating-point analysis; configure an ngspice engine and set spiceEngine="ngspice".', + }, + }) + continue } const effectId = `spice-simulation-${engineName}-${simulationExperimentId}` @@ -226,7 +253,9 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { group._queueAsyncEffect(effectId, async () => { try { debug(`Running simulation with engine: ${engineName}`) - const result = await spiceEngine.simulate(spiceString) + const result = await spiceEngine.simulate(spiceString, { + timeoutMs: analogSim._parsedProps.timeout, + }) debug( `Simulation completed, received ${result.simulationResultCircuitJson.length} elements`, @@ -273,6 +302,38 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { } } + if (isOperatingPointVoltage(element)) { + element.simulation_experiment_id = + simulationExperiment.simulation_experiment_id + + const probeMatch = + (element.simulation_voltage_probe_id + ? voltageProbesById.get(element.simulation_voltage_probe_id) + : undefined) ?? + (element.name ? graphNameToProbe.get(element.name) : undefined) + if (probeMatch) { + element.color = probeMatch.color ?? undefined + element.simulation_voltage_probe_id = + probeMatch.simulation_voltage_probe_id ?? undefined + } + } + + if (isOperatingPointCurrent(element)) { + element.simulation_experiment_id = + simulationExperiment.simulation_experiment_id + + const probeMatch = + (element.simulation_current_probe_id + ? currentProbesById.get(element.simulation_current_probe_id) + : undefined) ?? + (element.name ? currentProbesByName.get(element.name) : undefined) + if (probeMatch) { + element.color = probeMatch.color + element.simulation_current_probe_id = + probeMatch.simulation_current_probe_id + } + } + const insertedElement = root.db.insert(element) if (isVoltageGraph(insertedElement)) { insertedVoltageGraphs.push({ @@ -301,10 +362,10 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { group._markDirty("SimulationSpiceEngineRender") } catch (error) { debug(`Simulation failed for engine ${engineName}: ${error}`) - root.db.simulation_unknown_experiment_error.insert({ - simulation_experiment_id: simulationExperimentId, - error_type: "simulation_unknown_experiment_error", - message: error instanceof Error ? error.message : String(error), + insertSimulationExperimentError({ + db: root.db, + simulationExperimentId, + error, }) // Don't throw - allow other engines to continue } diff --git a/lib/components/primitive-components/Group/insert-simulation-experiment-error.ts b/lib/components/primitive-components/Group/insert-simulation-experiment-error.ts new file mode 100644 index 000000000..66dd41f4f --- /dev/null +++ b/lib/components/primitive-components/Group/insert-simulation-experiment-error.ts @@ -0,0 +1,77 @@ +import type { CircuitJsonUtilObjects } from "@tscircuit/circuit-json-util" +import type { SimulationExperimentErrorCode } from "circuit-json" + +const errorCodes = new Set([ + "non_convergent", + "timeout", + "missing_model", + "unsupported_analysis", + "invalid_netlist", + "engine_error", +]) + +const inferErrorCode = (message: string): SimulationExperimentErrorCode => { + const normalized = message.toLowerCase() + if (/timed?\s*out|timeout/.test(normalized)) return "timeout" + if ( + /converg|timestep too small|iteration limit|singular matrix/.test( + normalized, + ) + ) { + return "non_convergent" + } + if (/missing .*model|unknown subckt|unknown model/.test(normalized)) { + return "missing_model" + } + if (/unsupported .*analysis/.test(normalized)) return "unsupported_analysis" + if (/invalid netlist|syntax error|parse error/.test(normalized)) { + return "invalid_netlist" + } + return "engine_error" +} + +export const insertSimulationExperimentError = ({ + db, + simulationExperimentId, + error, +}: { + db: CircuitJsonUtilObjects + simulationExperimentId: string + error: unknown +}) => { + const errorObject = + error && typeof error === "object" + ? (error as { + code?: unknown + diagnostics?: unknown + message?: unknown + }) + : undefined + const message = + typeof errorObject?.message === "string" + ? errorObject.message + : error instanceof Error + ? error.message + : String(error) + const explicitCode = errorObject?.code + const errorCode = + typeof explicitCode === "string" && + errorCodes.has(explicitCode as SimulationExperimentErrorCode) + ? (explicitCode as SimulationExperimentErrorCode) + : inferErrorCode(message) + const rawDiagnostics = errorObject?.diagnostics + const diagnostics = Array.isArray(rawDiagnostics) + ? rawDiagnostics.filter((item): item is string => typeof item === "string") + : typeof rawDiagnostics === "string" + ? [rawDiagnostics] + : undefined + + return db.simulation_experiment_error.insert({ + error_type: "simulation_experiment_error", + simulation_experiment_id: simulationExperimentId, + error_code: errorCode, + message, + diagnostics: diagnostics?.length ? diagnostics : undefined, + is_fatal: true, + }) +} diff --git a/lib/components/primitive-components/Group/is-operating-point-current.ts b/lib/components/primitive-components/Group/is-operating-point-current.ts new file mode 100644 index 000000000..7ff6b135a --- /dev/null +++ b/lib/components/primitive-components/Group/is-operating-point-current.ts @@ -0,0 +1,8 @@ +import type { AnyCircuitElementInput } from "circuit-json" + +export const isOperatingPointCurrent = ( + element: AnyCircuitElementInput, +): element is Extract< + AnyCircuitElementInput, + { type: "simulation_operating_point_current" } +> => element.type === "simulation_operating_point_current" diff --git a/lib/components/primitive-components/Group/is-operating-point-voltage.ts b/lib/components/primitive-components/Group/is-operating-point-voltage.ts new file mode 100644 index 000000000..828e4c593 --- /dev/null +++ b/lib/components/primitive-components/Group/is-operating-point-voltage.ts @@ -0,0 +1,8 @@ +import type { AnyCircuitElementInput } from "circuit-json" + +export const isOperatingPointVoltage = ( + element: AnyCircuitElementInput, +): element is Extract< + AnyCircuitElementInput, + { type: "simulation_operating_point_voltage" } +> => element.type === "simulation_operating_point_voltage" diff --git a/package.json b/package.json index 62da2718f..0bae979a1 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,8 @@ "@tscircuit/matchpack": "^0.0.34", "@tscircuit/math-utils": "^0.0.36", "@tscircuit/miniflex": "^0.0.4", - "@tscircuit/ngspice-spice-engine": "^0.0.19", - "@tscircuit/props": "^0.0.577", + "@tscircuit/ngspice-spice-engine": "^0.0.20", + "@tscircuit/props": "^0.0.578", "@tscircuit/schematic-match-adapt": "^0.0.18", "@tscircuit/schematic-trace-solver": "^0.0.96", "@tscircuit/solver-utils": "^0.0.16", @@ -64,12 +64,12 @@ "bun-match-svg": "0.0.12", "calculate-elbow": "^0.0.12", "chokidar-cli": "^3.0.0", - "circuit-json": "^0.0.447", + "circuit-json": "^0.0.449", "circuit-json-to-bpc": "^0.0.13", "circuit-json-to-connectivity-map": "^0.0.23", "circuit-json-to-gltf": "^0.0.105", - "circuit-json-to-spice": "^0.0.43", - "circuit-to-svg": "^0.0.387", + "circuit-json-to-spice": "^0.0.45", + "circuit-to-svg": "^0.0.388", "concurrently": "^9.1.2", "connectivity-map": "^1.0.0", "debug": "^4.3.6", diff --git a/tests/features/spice-analysis/__snapshots__/operating-point-analysis-simulation.snap.svg b/tests/features/spice-analysis/__snapshots__/operating-point-analysis-simulation.snap.svg new file mode 100644 index 000000000..c815f3fb6 --- /dev/null +++ b/tests/features/spice-analysis/__snapshots__/operating-point-analysis-simulation.snap.svg @@ -0,0 +1,22 @@ +V15VI_LOADR11kΩR21kΩU_BOUNDARYVINVOUTbiasDC operating-point resultsMEASUREMENTTYPESOURCEVALUEVINVoltageVIN to 05 VVOUTVoltageVOUT to 02.5 VI_LOADCurrentsource_component_12.5 mA \ No newline at end of file diff --git a/tests/features/spice-analysis/operating-point-analysis.test.tsx b/tests/features/spice-analysis/operating-point-analysis.test.tsx new file mode 100644 index 000000000..8142a0027 --- /dev/null +++ b/tests/features/spice-analysis/operating-point-analysis.test.tsx @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test" +import createNgspiceSpiceEngine from "@tscircuit/ngspice-spice-engine" +import type { SpiceEngineSimulationOptions } from "@tscircuit/props" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("DC operating-point analysis returns scalar probe values", async () => { + let capturedSpice = "" + let capturedOptions: SpiceEngineSimulationOptions | undefined + const ngspiceEngine = await createNgspiceSpiceEngine() + const { circuit } = getTestFixture({ + platform: { + spiceEngineMap: { + ngspice: { + async simulate( + spiceString: string, + options?: SpiceEngineSimulationOptions, + ) { + capturedSpice = spiceString + capturedOptions = options + return ngspiceEngine.simulate(spiceString, options) + }, + }, + }, + }, + }) + + circuit.add( + + + .pin1", neg: ".R1 > .pin1" }} + /> + + + + + + + + + + + , + ) + + await circuit.renderUntilSettled() + + const experiment = circuit.db.simulation_experiment.list()[0]! + expect(experiment).toMatchObject({ + name: "bias", + experiment_type: "spice_dc_operating_point", + timeout_ms: 5000, + }) + expect(experiment.end_time_ms).toBeUndefined() + expect(capturedOptions).toEqual({ timeoutMs: 5000 }) + expect(capturedSpice).toContain(".PRINT OP") + expect(capturedSpice).toContain(".op") + expect(capturedSpice).not.toContain(".tran") + expect(capturedSpice).not.toContain("UIC") + + const voltages = circuit.db.simulation_operating_point_voltage.list() + const currents = circuit.db.simulation_operating_point_current.list() + expect(voltages).toHaveLength(2) + expect(currents).toHaveLength(1) + expect( + voltages.find((measurement) => measurement.name === "VIN")?.voltage, + ).toBeCloseTo(5) + expect( + voltages.find((measurement) => measurement.name === "VOUT")?.voltage, + ).toBeCloseTo(2.5) + expect(Math.abs(currents[0]!.current)).toBeCloseTo(0.0025) + expect( + [...voltages, ...currents].every( + (measurement) => + measurement.simulation_experiment_id === + experiment.simulation_experiment_id, + ), + ).toBe(true) + expect(circuit.db.simulation_experiment_error.list()).toHaveLength(0) + expect( + circuit.db.source_component + .list() + .find((component) => component.name === "U_BOUNDARY"), + ).toMatchObject({ is_simulation_boundary: true }) + + await expect(circuit).toMatchSimulationSnapshot(import.meta.path) +}, 20_000) diff --git a/tests/features/spice-analysis/operating-point-missing-model.test.tsx b/tests/features/spice-analysis/operating-point-missing-model.test.tsx new file mode 100644 index 000000000..e0217307b --- /dev/null +++ b/tests/features/spice-analysis/operating-point-missing-model.test.tsx @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("unmodeled non-boundary chips produce a classified missing-model error", async () => { + let engineWasCalled = false + const { circuit } = getTestFixture({ + platform: { + spiceEngineMap: { + ngspice: { + async simulate() { + engineWasCalled = true + return { simulationResultCircuitJson: [] } + }, + }, + }, + }, + }) + + circuit.add( + + + + , + ) + + await circuit.renderUntilSettled() + + expect(engineWasCalled).toBe(false) + expect(circuit.db.simulation_experiment_error.list()).toEqual([ + expect.objectContaining({ + error_code: "missing_model", + message: "Missing SPICE model for U1", + is_fatal: true, + }), + ]) + expect(circuit.db.simulation_unknown_experiment_error.list()).toHaveLength(0) + expect(circuit.db.source_component.list()[0]).toMatchObject({ + is_simulation_boundary: false, + }) +}) diff --git a/tests/features/spice-analysis/spice-analysis03-rc-charging-voltage-divider.test.tsx b/tests/features/spice-analysis/spice-analysis03-rc-charging-voltage-divider.test.tsx index 4ee131056..f2f594f23 100644 --- a/tests/features/spice-analysis/spice-analysis03-rc-charging-voltage-divider.test.tsx +++ b/tests/features/spice-analysis/spice-analysis03-rc-charging-voltage-divider.test.tsx @@ -1,7 +1,7 @@ -import { test, expect } from "bun:test" +import { expect, test } from "bun:test" import type { SimulationTransientVoltageGraph } from "circuit-json" -import { getTestFixture } from "tests/fixtures/get-test-fixture" import { sel } from "lib/sel" +import { getTestFixture } from "tests/fixtures/get-test-fixture" test( "spice-analysis03-rc-charging-voltage-divider", @@ -12,6 +12,7 @@ test( , ) - expect(circuit.renderUntilSettled()).rejects.toThrow( - 'SPICE engine "ngspice" not found in platform config. Available engines: []', - ) + await circuit.renderUntilSettled() + + expect(circuit.db.simulation_experiment_error.list()).toEqual([ + expect.objectContaining({ + error_code: "engine_error", + message: + 'SPICE engine "ngspice" not found in platform config. Available engines: []', + is_fatal: true, + }), + ]) }, { timeout: 20000 }, ) diff --git a/tests/features/spice-analysis/spice-analysis07-simulation-fails.test.tsx b/tests/features/spice-analysis/spice-analysis07-simulation-fails.test.tsx index c9503de7b..ff3681a20 100644 --- a/tests/features/spice-analysis/spice-analysis07-simulation-fails.test.tsx +++ b/tests/features/spice-analysis/spice-analysis07-simulation-fails.test.tsx @@ -1,8 +1,8 @@ -import { test, expect } from "bun:test" +import { expect, test } from "bun:test" import { getTestFixture } from "tests/fixtures/get-test-fixture" test( - "a failing simulation should produce a simulation_unknown_experiment_error", + "a singular simulation should produce a classified non-convergent error", async () => { const { circuit } = getTestFixture() @@ -17,19 +17,16 @@ test( await circuit.renderUntilSettled() - const circuitJson = circuit.getCircuitJson() - - expect( - circuitJson.some( - (el) => el.type === "simulation_unknown_experiment_error", - ), - ).toBe(true) - - const errorEl = circuitJson.find( - (el) => el.type === "simulation_unknown_experiment_error", + expect(circuit.db.simulation_experiment_error.list()).toEqual([ + expect.objectContaining({ + error_code: "non_convergent", + message: "Singular matrix (real)", + is_fatal: true, + }), + ]) + expect(circuit.db.simulation_unknown_experiment_error.list()).toHaveLength( + 0, ) - - expect(errorEl).toBeDefined() }, { timeout: 20000 }, )