From a4079a2a34156bca3d1d20339732eb6df8021a9d Mon Sep 17 00:00:00 2001 From: ShiboSoftwareDev Date: Wed, 29 Jul 2026 01:55:08 +0200 Subject: [PATCH 1/3] feat: add analog stimulus and measurements --- lib/components/index.ts | 1 + .../normal-components/CurrentSource.ts | 9 + .../normal-components/VoltageSource.ts | 6 + .../AnalogAnalysisSimulation.ts | 10 - .../primitive-components/AnalogMeasurement.ts | 22 ++ ...weepParameter_doInitialSimulationRender.ts | 11 + ...up_doInitialSimulationSpiceEngineRender.ts | 104 +++++++-- ...attachSweepCoordinateToSimulationResult.ts | 15 +- .../Group/createSimulationRuns.ts | 68 ++++-- .../Group/evaluateAnalogMeasurement.ts | 174 +++++++++++++++ .../implicit-measurement-voltage-probes.ts | 65 ++++++ lib/fiber/intrinsic-jsx.ts | 1 + lib/namespaced-elements/analog.ts | 3 + ...sional-parameter-sweep-simulation.snap.svg | 30 +++ ...transient-measurements-simulation.snap.svg | 30 +++ ...linear-source-waveforms-schematic.snap.svg | 12 ++ ...-multidimensional-parameter-sweep.test.tsx | 92 ++++++++ .../analog-transient-measurements.test.tsx | 201 ++++++++++++++++++ ...piecewise-linear-source-waveforms.test.tsx | 69 ++++++ 19 files changed, 877 insertions(+), 46 deletions(-) create mode 100644 lib/components/primitive-components/AnalogMeasurement.ts create mode 100644 lib/components/primitive-components/Group/evaluateAnalogMeasurement.ts create mode 100644 lib/components/primitive-components/Group/implicit-measurement-voltage-probes.ts create mode 100644 tests/features/spice-analysis/__snapshots__/analog-multidimensional-parameter-sweep-simulation.snap.svg create mode 100644 tests/features/spice-analysis/__snapshots__/analog-transient-measurements-simulation.snap.svg create mode 100644 tests/features/spice-analysis/__snapshots__/piecewise-linear-source-waveforms-schematic.snap.svg create mode 100644 tests/features/spice-analysis/analog-multidimensional-parameter-sweep.test.tsx create mode 100644 tests/features/spice-analysis/analog-transient-measurements.test.tsx create mode 100644 tests/features/spice-analysis/piecewise-linear-source-waveforms.test.tsx diff --git a/lib/components/index.ts b/lib/components/index.ts index 9bee95294..59d361e30 100644 --- a/lib/components/index.ts +++ b/lib/components/index.ts @@ -103,5 +103,6 @@ export { AnalogDcOperatingPointSimulation } from "./primitive-components/AnalogD export { AnalogDcSweepSimulation } from "./primitive-components/AnalogDcSweepSimulation" export { AnalogAcSweepSimulation } from "./primitive-components/AnalogAcSweepSimulation" export { AnalogSweepParameter } from "./primitive-components/AnalogSweepParameter" +export { AnalogMeasurement } from "./primitive-components/AnalogMeasurement" export { VoltageProbe } from "./primitive-components/VoltageProbe" export { SpiceModel } from "./primitive-components/SpiceModel" diff --git a/lib/components/normal-components/CurrentSource.ts b/lib/components/normal-components/CurrentSource.ts index 6ee1093bb..71f26eb0d 100644 --- a/lib/components/normal-components/CurrentSource.ts +++ b/lib/components/normal-components/CurrentSource.ts @@ -106,6 +106,7 @@ export class CurrentSource extends NormalComponent< props.frequency !== undefined || props.peakToPeakCurrent !== undefined || props.waveShape !== undefined || + props.currentWaveform !== undefined || props.acMagnitude !== undefined || props.acPhase !== undefined @@ -123,6 +124,14 @@ export class CurrentSource extends NormalComponent< wave_shape: props.waveShape, phase: props.phase, duty_cycle: props.dutyCycle, + current_waveform: props.currentWaveform + ? { + timestamps_ms: props.currentWaveform.map((point) => point.time), + current_values: props.currentWaveform.map( + (point) => point.current, + ), + } + : undefined, ac_magnitude: props.acMagnitude, ac_phase: props.acPhase, }) diff --git a/lib/components/normal-components/VoltageSource.ts b/lib/components/normal-components/VoltageSource.ts index 62960d88e..d2e81d0be 100644 --- a/lib/components/normal-components/VoltageSource.ts +++ b/lib/components/normal-components/VoltageSource.ts @@ -120,6 +120,12 @@ export class VoltageSource extends NormalComponent< fall_time: props.fallTime, pulse_width: props.pulseWidth, period: props.period, + voltage_waveform: props.voltageWaveform + ? { + timestamps_ms: props.voltageWaveform.map((point) => point.time), + voltage_values: props.voltageWaveform.map((point) => point.voltage), + } + : undefined, ac_magnitude: props.acMagnitude, ac_phase: props.acPhase, }) diff --git a/lib/components/primitive-components/AnalogAnalysisSimulation.ts b/lib/components/primitive-components/AnalogAnalysisSimulation.ts index ceb8bfa20..38208423b 100644 --- a/lib/components/primitive-components/AnalogAnalysisSimulation.ts +++ b/lib/components/primitive-components/AnalogAnalysisSimulation.ts @@ -30,16 +30,6 @@ export abstract class AnalogAnalysisSimulation< ) } - const sweepParameterCount = this.children.filter( - (child) => child.componentName === "AnalogSweepParameter", - ).length - if (sweepParameterCount > 1) { - this.renderError( - "An analog simulation can contain at most one sweep parameter.", - ) - return null - } - const simulationExperiment = this.insertSimulationExperiment() this.simulation_experiment_id = simulationExperiment?.simulation_experiment_id ?? null diff --git a/lib/components/primitive-components/AnalogMeasurement.ts b/lib/components/primitive-components/AnalogMeasurement.ts new file mode 100644 index 000000000..f0b4fdff5 --- /dev/null +++ b/lib/components/primitive-components/AnalogMeasurement.ts @@ -0,0 +1,22 @@ +import { analogMeasurementProps } from "@tscircuit/props" +import { PrimitiveComponent } from "../base-components/PrimitiveComponent" +import { AnalogTransientSimulation } from "./AnalogTransientSimulation" + +export class AnalogMeasurement extends PrimitiveComponent< + typeof analogMeasurementProps +> { + get config() { + return { + componentName: "AnalogMeasurement", + zodProps: analogMeasurementProps, + } + } + + doInitialSimulationRender(): void { + if (!(this.parent instanceof AnalogTransientSimulation)) { + this.renderError( + "analog.measurement must be nested directly in analog.transientsimulation.", + ) + } + } +} diff --git a/lib/components/primitive-components/AnalogSweepParameter_doInitialSimulationRender.ts b/lib/components/primitive-components/AnalogSweepParameter_doInitialSimulationRender.ts index d9e8b2d26..f236ff593 100644 --- a/lib/components/primitive-components/AnalogSweepParameter_doInitialSimulationRender.ts +++ b/lib/components/primitive-components/AnalogSweepParameter_doInitialSimulationRender.ts @@ -96,6 +96,15 @@ export const AnalogSweepParameter_doInitialSimulationRender = ( stop: props.stop!, step: props.step!, }) + if ( + props.displayValues && + props.displayValues.length !== parameterSweepCoordinates.length + ) { + analogSweepParameter.renderError( + "analog.sweepparameter displayValues must match the generated coordinate count.", + ) + return + } const simulationScope = analogSweepParameter.parent.getGroup() ?? analogSweepParameter.parent.getSubcircuit() @@ -109,6 +118,8 @@ export const AnalogSweepParameter_doInitialSimulationRender = ( parameter_type: props.parameterType, parameter_values: parameterSweepCoordinates, parameter_unit: parameterUnit, + display_parameter_values: props.displayValues, + display_parameter_unit: props.displayUnit, } let simulationParameterSweepId: SimulationParameterSweepId diff --git a/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts b/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts index ca3d68a02..4dd2307ef 100644 --- a/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts +++ b/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts @@ -12,18 +12,24 @@ import type { Ammeter } from "../../normal-components/Ammeter" import type { AnalogAcSweepSimulation } from "../AnalogAcSweepSimulation" import type { AnalogDcOperatingPointSimulation } from "../AnalogDcOperatingPointSimulation" import type { AnalogDcSweepSimulation } from "../AnalogDcSweepSimulation" +import type { AnalogMeasurement } from "../AnalogMeasurement" import type { AnalogSimulation } from "../AnalogSimulation" -import type { AnalogTransientSimulation } from "../AnalogTransientSimulation" +import { AnalogTransientSimulation } from "../AnalogTransientSimulation" import type { VoltageProbe } from "../VoltageProbe" import type { GraphDisplayOverrides } from "./GraphDisplayOverrides" import type { Group } from "./Group" import type { InsertedSimulationGraph } from "./InsertedSimulationGraph" import { attachSweepCoordinateToSimulationResult } from "./attachSweepCoordinateToSimulationResult" import { createSimulationRuns } from "./createSimulationRuns" +import { evaluateAnalogMeasurement } from "./evaluateAnalogMeasurement" import { getAmmeterGraphDisplayOverrides, getVoltageProbeGraphDisplayOverrides, } from "./getGraphDisplayOverrides" +import { + addImplicitMeasurementVoltageProbes, + isImplicitMeasurementVoltageProbeId, +} from "./implicit-measurement-voltage-probes" import { insertIndependentAxisScopeTraces } from "./insertIndependentAxisScopeTraces" import { isCircuitElementInput } from "./isCircuitElementInput" import { isCurrentGraph } from "./isCurrentGraph" @@ -124,6 +130,7 @@ const getCircuitJsonForAnalogSimulation = ({ element.type === "simulation_dc_sweep_current_graph" || element.type === "simulation_ac_sweep_voltage_graph" || element.type === "simulation_ac_sweep_current_graph" || + element.type === "simulation_measurement_result" || element.type === "simulation_unknown_experiment_error" ) { return false @@ -175,15 +182,26 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { } const simulationScope = analogSimulation.getGroup() ?? group + const analogMeasurements = + analogSimulation instanceof AnalogTransientSimulation + ? analogSimulation.children.filter( + (child): child is AnalogMeasurement => + child.componentName === "AnalogMeasurement", + ) + : [] const voltageProbes = simulationScope.selectAll("voltageprobe") const ammeters = simulationScope.selectAll("ammeter") - const circuitJson = getCircuitJsonForAnalogSimulation({ + const scopedCircuitJson = getCircuitJsonForAnalogSimulation({ circuitJson: root.db.toArray(), simulationExperimentId, voltageProbes, ammeters, }) + const circuitJson = + analogMeasurements.length > 0 + ? addImplicitMeasurementVoltageProbes(scopedCircuitJson) + : scopedCircuitJson let simulationRuns: ReturnType try { @@ -308,8 +326,18 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { // Add simulation results to the database const insertedVoltageGraphs: InsertedSimulationGraph[] = [] const insertedCurrentGraphs: InsertedSimulationGraph[] = [] + const measurementValuesByComponent = new Map< + AnalogMeasurement, + number[] + >( + analogMeasurements.map((analogMeasurement) => [ + analogMeasurement, + [], + ]), + ) for (const simulationRun of simulationRuns) { + const insertedSimulationGraphsForRun: InsertedSimulationGraph[] = [] debug(`Running simulation with engine: ${engineName}`) const simulationResult = await spiceEngine.simulate( simulationRun.spiceString, @@ -328,8 +356,8 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { const simulationResultWithSweepCoordinate = attachSweepCoordinateToSimulationResult({ simulationResult: simulationResultCircuitElement, - simulationParameterSweepCoordinate: - simulationRun.simulationParameterSweepCoordinate, + simulationParameterSweepCoordinates: + simulationRun.simulationParameterSweepCoordinates, }) if (isVoltageGraph(simulationResultWithSweepCoordinate)) { @@ -410,25 +438,77 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { } } - const insertedSimulationResult = root.db.insert( - simulationResultWithSweepCoordinate, - ) + const isImplicitMeasurementGraph = + isVoltageGraph(simulationResultWithSweepCoordinate) && + isImplicitMeasurementVoltageProbeId( + simulationResultWithSweepCoordinate.source_probe_id, + ) + const insertedSimulationResult = isImplicitMeasurementGraph + ? simulationResultWithSweepCoordinate + : root.db.insert(simulationResultWithSweepCoordinate) if (isVoltageGraph(insertedSimulationResult)) { - insertedVoltageGraphs.push({ + const insertedVoltageGraph: InsertedSimulationGraph = { type: "voltage", graph: insertedSimulationResult, - }) + } + if (!isImplicitMeasurementGraph) { + insertedVoltageGraphs.push(insertedVoltageGraph) + } + insertedSimulationGraphsForRun.push(insertedVoltageGraph) } if (isCurrentGraph(insertedSimulationResult)) { - insertedCurrentGraphs.push({ + const insertedCurrentGraph: InsertedSimulationGraph = { type: "current", graph: insertedSimulationResult, - }) + } + insertedCurrentGraphs.push(insertedCurrentGraph) + insertedSimulationGraphsForRun.push(insertedCurrentGraph) } debug( - `Inserted ${simulationResultWithSweepCoordinate.type} into database`, + isImplicitMeasurementGraph + ? `Collected ${simulationResultWithSweepCoordinate.type} for measurement` + : `Inserted ${simulationResultWithSweepCoordinate.type} into database`, ) } + + for (const analogMeasurement of analogMeasurements) { + const measurementValues = + measurementValuesByComponent.get(analogMeasurement) + if (!measurementValues) { + throw new Error("Analog measurement result collector is missing") + } + measurementValues.push( + evaluateAnalogMeasurement({ + analogMeasurement, + simulationScope, + simulationGraphs: insertedSimulationGraphsForRun, + }), + ) + } + } + + for (const analogMeasurement of analogMeasurements) { + const measurementValues = + measurementValuesByComponent.get(analogMeasurement) + if (!measurementValues) { + throw new Error("Analog measurement result collector is missing") + } + const simulationParameterSweepCoordinateSets = simulationRuns.map( + (simulationRun) => + simulationRun.simulationParameterSweepCoordinates ?? [], + ) + const hasParameterSweep = simulationParameterSweepCoordinateSets.some( + (coordinateSet) => coordinateSet.length > 0, + ) + root.db.simulation_measurement_result.insert({ + simulation_experiment_id: simulationExperimentId, + name: analogMeasurement._parsedProps.name, + measurement_values: measurementValues, + measurement_unit: analogMeasurement._parsedProps.unit, + simulation_parameter_sweep_coordinate_sets: hasParameterSweep + ? simulationParameterSweepCoordinateSets + : undefined, + }) } if (analogSimulation.usesIndependentGraphAxes()) { diff --git a/lib/components/primitive-components/Group/attachSweepCoordinateToSimulationResult.ts b/lib/components/primitive-components/Group/attachSweepCoordinateToSimulationResult.ts index d1459171e..b64aa4932 100644 --- a/lib/components/primitive-components/Group/attachSweepCoordinateToSimulationResult.ts +++ b/lib/components/primitive-components/Group/attachSweepCoordinateToSimulationResult.ts @@ -6,13 +6,13 @@ import { isSimulationAnalysisResult } from "./isSimulationAnalysisResult" export const attachSweepCoordinateToSimulationResult = ({ simulationResult, - simulationParameterSweepCoordinate, + simulationParameterSweepCoordinates, }: { simulationResult: AnyCircuitElementInput - simulationParameterSweepCoordinate?: SimulationParameterSweepCoordinate + simulationParameterSweepCoordinates?: SimulationParameterSweepCoordinate[] }): AnyCircuitElementInput => { if ( - !simulationParameterSweepCoordinate || + !simulationParameterSweepCoordinates?.length || !isSimulationAnalysisResult(simulationResult) ) { return simulationResult @@ -24,7 +24,12 @@ export const attachSweepCoordinateToSimulationResult = ({ simulationResultWithSweepCoordinate, simulationResultIdKey, ) - simulationResultWithSweepCoordinate.simulation_parameter_sweep_coordinate = - simulationParameterSweepCoordinate + if (simulationParameterSweepCoordinates.length === 1) { + simulationResultWithSweepCoordinate.simulation_parameter_sweep_coordinate = + simulationParameterSweepCoordinates[0] + } else { + simulationResultWithSweepCoordinate.simulation_parameter_sweep_coordinates = + simulationParameterSweepCoordinates + } return simulationResultWithSweepCoordinate } diff --git a/lib/components/primitive-components/Group/createSimulationRuns.ts b/lib/components/primitive-components/Group/createSimulationRuns.ts index 3550517e3..507c3e32a 100644 --- a/lib/components/primitive-components/Group/createSimulationRuns.ts +++ b/lib/components/primitive-components/Group/createSimulationRuns.ts @@ -12,21 +12,21 @@ type SimulationExperimentId = SimulationExperiment["simulation_experiment_id"] export interface SimulationRun { spiceNetlist: SpiceNetlist spiceString: string - simulationParameterSweepCoordinate?: SimulationParameterSweepCoordinate + simulationParameterSweepCoordinates?: SimulationParameterSweepCoordinate[] } const createSimulationRun = ({ circuitJson, - simulationParameterSweepCoordinate, + simulationParameterSweepCoordinates, }: { circuitJson: AnyCircuitElement[] - simulationParameterSweepCoordinate?: SimulationParameterSweepCoordinate + simulationParameterSweepCoordinates?: SimulationParameterSweepCoordinate[] }): SimulationRun => { const spiceNetlist = circuitJsonToSpice(circuitJson) return { spiceNetlist, spiceString: spiceNetlist.toSpiceString(), - simulationParameterSweepCoordinate, + simulationParameterSweepCoordinates, } } @@ -37,31 +37,61 @@ export const createSimulationRuns = ({ circuitJson: AnyCircuitElement[] simulationExperimentId: SimulationExperimentId }): SimulationRun[] => { - const parameterSweep = circuitJson.find( + const parameterSweeps = circuitJson.filter( (circuitElement): circuitElement is SimulationParameterSweep => circuitElement.type === "simulation_parameter_sweep" && circuitElement.simulation_experiment_id === simulationExperimentId, ) - if (!parameterSweep) { + if (parameterSweeps.length === 0) { return [createSimulationRun({ circuitJson })] } - return parameterSweep.parameter_values.map((parameterValue, sweepIndex) => { - const simulationParameterSweepCoordinate: SimulationParameterSweepCoordinate = - { - simulation_parameter_sweep_id: - parameterSweep.simulation_parameter_sweep_id, - sweep_index: sweepIndex, - parameter_value: parameterValue, - parameter_unit: parameterSweep.parameter_unit, + let sweepCoordinateSets: SimulationParameterSweepCoordinate[][] = [[]] + for (const parameterSweep of parameterSweeps) { + const runCount = + sweepCoordinateSets.length * parameterSweep.parameter_values.length + if (runCount > 1_000_000) { + throw new Error( + "Parameter sweep Cartesian product exceeds 1,000,000 runs", + ) + } + sweepCoordinateSets = sweepCoordinateSets.flatMap((coordinateSet) => + parameterSweep.parameter_values.map((parameterValue, sweepIndex) => [ + ...coordinateSet, + { + simulation_parameter_sweep_id: + parameterSweep.simulation_parameter_sweep_id, + sweep_index: sweepIndex, + parameter_value: parameterValue, + parameter_unit: parameterSweep.parameter_unit, + }, + ]), + ) + } + + return sweepCoordinateSets.map((simulationParameterSweepCoordinates) => { + let sweptCircuitJson = circuitJson + for ( + let parameterSweepIndex = 0; + parameterSweepIndex < parameterSweeps.length; + parameterSweepIndex++ + ) { + const parameterSweep = parameterSweeps[parameterSweepIndex] + const simulationParameterSweepCoordinate = + simulationParameterSweepCoordinates[parameterSweepIndex] + if (!parameterSweep || !simulationParameterSweepCoordinate) { + throw new Error("Parameter sweep coordinate set is incomplete") } - return createSimulationRun({ - circuitJson: applyParameterSweepCoordinate({ - circuitJson, + sweptCircuitJson = applyParameterSweepCoordinate({ + circuitJson: sweptCircuitJson, parameterSweep, simulationParameterSweepCoordinate, - }), - simulationParameterSweepCoordinate, + }) + } + + return createSimulationRun({ + circuitJson: sweptCircuitJson, + simulationParameterSweepCoordinates, }) }) } diff --git a/lib/components/primitive-components/Group/evaluateAnalogMeasurement.ts b/lib/components/primitive-components/Group/evaluateAnalogMeasurement.ts new file mode 100644 index 000000000..fd2bdb21b --- /dev/null +++ b/lib/components/primitive-components/Group/evaluateAnalogMeasurement.ts @@ -0,0 +1,174 @@ +import type { + AnalogTransientMeasurementContext, + TransientMeasurementSeries, +} from "@tscircuit/props" +import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map" +import type { Ammeter } from "lib/components/normal-components/Ammeter" +import type { AnalogMeasurement } from "../AnalogMeasurement" +import type { Net } from "../Net" +import type { Port } from "../Port" +import type { VoltageProbe } from "../VoltageProbe" +import type { IGroup } from "./IGroup" +import type { InsertedSimulationGraph } from "./InsertedSimulationGraph" +import { getImplicitMeasurementVoltageProbeId } from "./implicit-measurement-voltage-probes" + +type SimulationMeasurementScope = Pick + +const getTimestampsMs = ( + graph: + | Extract["graph"] + | Extract["graph"], + sampleCount: number, +) => + graph.timestamps_ms ?? + Array.from( + { length: sampleCount }, + (_, sampleIndex) => graph.start_time_ms + sampleIndex * graph.time_per_step, + ) + +const getVoltageProbeId = ({ + selector, + simulationScope, +}: { + selector: string + simulationScope: SimulationMeasurementScope +}) => { + const selectedComponent = simulationScope.selectOne(selector, { + type: "voltageprobe", + }) + if ( + selectedComponent?.componentName === "VoltageProbe" && + (selectedComponent as VoltageProbe).simulation_voltage_probe_id + ) { + return (selectedComponent as VoltageProbe).simulation_voltage_probe_id + } + + const selectedPort = simulationScope.selectOne(selector, { + type: "port", + }) + const selectedNet = selectedPort + ? null + : (simulationScope.selectOne(selector, { type: "net" }) ?? + (selector.startsWith("net.") + ? simulationScope.selectOne(selector.slice(4), { type: "net" }) + : null)) + const sourcePortId = selectedPort?.source_port_id + const sourceNetId = + selectedNet?.source_net_id ?? + (selector.startsWith("net.") + ? simulationScope.root?.db.source_net + .list() + .find((sourceNet) => sourceNet.name === selector.slice(4)) + ?.source_net_id + : undefined) + const sourceTargetId = sourcePortId ?? sourceNetId + const circuitJson = simulationScope.root?.db.toArray() ?? [] + const connectivityMap = getFullConnectivityMapFromCircuitJson(circuitJson) + const getConnectionKey = (sourcePortOrNetId: string) => + connectivityMap.getNetConnectedToId(sourcePortOrNetId) ?? sourcePortOrNetId + const targetConnectionKey = sourceTargetId + ? getConnectionKey(sourceTargetId) + : undefined + const matchingProbe = + simulationScope.root?.db.simulation_voltage_probe.list().find((probe) => { + const probeSourceId = + probe.signal_input_source_port_id ?? probe.signal_input_source_net_id + return ( + probeSourceId !== undefined && + targetConnectionKey !== undefined && + getConnectionKey(probeSourceId) === targetConnectionKey + ) + }) ?? null + + if (matchingProbe) { + return matchingProbe.simulation_voltage_probe_id + } + if (targetConnectionKey) { + return getImplicitMeasurementVoltageProbeId(targetConnectionKey) + } + throw new Error( + `getVoltage("${selector}") could not resolve a voltage-capable port or net.`, + ) +} + +const getCurrentProbeId = ({ + selector, + simulationScope, +}: { + selector: string + simulationScope: SimulationMeasurementScope +}) => { + const selectedComponent = simulationScope.selectOne(selector, { + type: "ammeter", + }) + if ( + selectedComponent?.componentName !== "Ammeter" || + !(selectedComponent as Ammeter).simulation_current_probe_id + ) { + throw new Error( + `getCurrent("${selector}") requires an ammeter matching that selector.`, + ) + } + return (selectedComponent as Ammeter).simulation_current_probe_id +} + +export const evaluateAnalogMeasurement = ({ + analogMeasurement, + simulationScope, + simulationGraphs, +}: { + analogMeasurement: AnalogMeasurement + simulationScope: SimulationMeasurementScope + simulationGraphs: InsertedSimulationGraph[] +}) => { + const context: AnalogTransientMeasurementContext = { + getVoltage: (selector): TransientMeasurementSeries => { + const voltageProbeId = getVoltageProbeId({ selector, simulationScope }) + const voltageGraph = simulationGraphs.find( + (simulationGraph) => + simulationGraph.type === "voltage" && + simulationGraph.graph.source_probe_id === voltageProbeId, + ) + if (!voltageGraph || voltageGraph.type !== "voltage") { + throw new Error( + `getVoltage("${selector}") could not find a transient result.`, + ) + } + return { + timestampsMs: getTimestampsMs( + voltageGraph.graph, + voltageGraph.graph.voltage_levels.length, + ), + values: voltageGraph.graph.voltage_levels, + } + }, + getCurrent: (selector): TransientMeasurementSeries => { + const currentProbeId = getCurrentProbeId({ selector, simulationScope }) + const currentGraph = simulationGraphs.find( + (simulationGraph) => + simulationGraph.type === "current" && + simulationGraph.graph.source_probe_id === currentProbeId, + ) + if (!currentGraph || currentGraph.type !== "current") { + throw new Error( + `getCurrent("${selector}") could not find a transient result.`, + ) + } + return { + timestampsMs: getTimestampsMs( + currentGraph.graph, + currentGraph.graph.current_levels.length, + ), + values: currentGraph.graph.current_levels, + } + }, + } + + const measurementValue = analogMeasurement._parsedProps.measureFn(context) + if (!Number.isFinite(measurementValue)) { + throw new Error( + `analog.measurement "${analogMeasurement._parsedProps.name}" returned a non-finite value.`, + ) + } + return measurementValue +} diff --git a/lib/components/primitive-components/Group/implicit-measurement-voltage-probes.ts b/lib/components/primitive-components/Group/implicit-measurement-voltage-probes.ts new file mode 100644 index 000000000..53fb2926a --- /dev/null +++ b/lib/components/primitive-components/Group/implicit-measurement-voltage-probes.ts @@ -0,0 +1,65 @@ +import type { AnyCircuitElement, SimulationVoltageProbe } from "circuit-json" +import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map" + +const implicitMeasurementProbeIdPrefix = [ + "simulation_voltage_probe", + "analog_measurement", + "", +].join("_") + +export const getImplicitMeasurementVoltageProbeId = (connectionKey: string) => + `${implicitMeasurementProbeIdPrefix}${connectionKey}` + +export const isImplicitMeasurementVoltageProbeId = ( + simulationVoltageProbeId?: string | null, +) => simulationVoltageProbeId?.startsWith(implicitMeasurementProbeIdPrefix) + +export const addImplicitMeasurementVoltageProbes = ( + circuitJson: AnyCircuitElement[], +): AnyCircuitElement[] => { + const connectivityMap = getFullConnectivityMapFromCircuitJson(circuitJson) + const getConnectionKey = (sourcePortOrNetId: string) => + connectivityMap.getNetConnectedToId(sourcePortOrNetId) ?? sourcePortOrNetId + const probedConnectionKeys = new Set() + + for (const circuitElement of circuitJson) { + if (circuitElement.type !== "simulation_voltage_probe") continue + const signalSourceId = + circuitElement.signal_input_source_port_id ?? + circuitElement.signal_input_source_net_id + if (!signalSourceId) continue + probedConnectionKeys.add(getConnectionKey(signalSourceId)) + } + + const implicitVoltageProbes: SimulationVoltageProbe[] = [] + for (const circuitElement of circuitJson) { + if (circuitElement.type === "source_port") { + const connectionKey = getConnectionKey(circuitElement.source_port_id) + if (probedConnectionKeys.has(connectionKey)) continue + probedConnectionKeys.add(connectionKey) + implicitVoltageProbes.push({ + type: "simulation_voltage_probe", + simulation_voltage_probe_id: + getImplicitMeasurementVoltageProbeId(connectionKey), + name: `analog_measurement_port_${circuitElement.source_port_id}`, + signal_input_source_port_id: circuitElement.source_port_id, + subcircuit_id: circuitElement.subcircuit_id, + }) + } + if (circuitElement.type === "source_net" && !circuitElement.is_ground) { + const connectionKey = getConnectionKey(circuitElement.source_net_id) + if (probedConnectionKeys.has(connectionKey)) continue + probedConnectionKeys.add(connectionKey) + implicitVoltageProbes.push({ + type: "simulation_voltage_probe", + simulation_voltage_probe_id: + getImplicitMeasurementVoltageProbeId(connectionKey), + name: `analog_measurement_net_${circuitElement.source_net_id}`, + signal_input_source_net_id: circuitElement.source_net_id, + subcircuit_id: circuitElement.subcircuit_id, + }) + } + } + + return [...circuitJson, ...implicitVoltageProbes] +} diff --git a/lib/fiber/intrinsic-jsx.ts b/lib/fiber/intrinsic-jsx.ts index a0c09349c..9a1108fd2 100644 --- a/lib/fiber/intrinsic-jsx.ts +++ b/lib/fiber/intrinsic-jsx.ts @@ -102,6 +102,7 @@ export interface TscircuitElements { analogdcsweepsimulation: Props.AnalogDcSweepSimulationProps analogacsweepsimulation: Props.AnalogAcSweepSimulationProps analogsweepparameter: Props.AnalogSweepParameterProps + analogmeasurement: Props.AnalogMeasurementProps spicemodel: Props.SpiceModelProps fiducial: Props.FiducialProps jscad: any diff --git a/lib/namespaced-elements/analog.ts b/lib/namespaced-elements/analog.ts index 499a0480e..3ec2f445c 100644 --- a/lib/namespaced-elements/analog.ts +++ b/lib/namespaced-elements/analog.ts @@ -2,6 +2,7 @@ import type { AnalogAcSweepSimulationProps, AnalogDcOperatingPointSimulationProps, AnalogDcSweepSimulationProps, + AnalogMeasurementProps, AnalogSweepParameterProps, AnalogTransientSimulationProps, } from "@tscircuit/props" @@ -24,4 +25,6 @@ export const analog = { sweepparameter: createNamespacedElement( "analogsweepparameter", ), + measurement: + createNamespacedElement("analogmeasurement"), } as const diff --git a/tests/features/spice-analysis/__snapshots__/analog-multidimensional-parameter-sweep-simulation.snap.svg b/tests/features/spice-analysis/__snapshots__/analog-multidimensional-parameter-sweep-simulation.snap.svg new file mode 100644 index 000000000..870887bfb --- /dev/null +++ b/tests/features/spice-analysis/__snapshots__/analog-multidimensional-parameter-sweep-simulation.snap.svg @@ -0,0 +1,30 @@ +V15VR11kΩC11uFGNDVOUTVOUT-10101234567Voltage (V)Operating PointVOUT (100Ω, 0.000001F)VOUT (100Ω, 0.000002F)VOUT (100Ω, 0.000003F)VOUT (200Ω, 0.000001F)VOUT (200Ω, 0.000002F)VOUT (200Ω, 0.000003F)sweep \ No newline at end of file diff --git a/tests/features/spice-analysis/__snapshots__/analog-transient-measurements-simulation.snap.svg b/tests/features/spice-analysis/__snapshots__/analog-transient-measurements-simulation.snap.svg new file mode 100644 index 000000000..abc012dc9 --- /dev/null +++ b/tests/features/spice-analysis/__snapshots__/analog-transient-measurements-simulation.snap.svg @@ -0,0 +1,30 @@ +VIN5VIINRLOAD1kΩGNDVOUT1000120014001600180020000.511.522.533.5Measurementresistance (Ω)settled-output-voltagesettled-input-currentmeasured-load \ No newline at end of file diff --git a/tests/features/spice-analysis/__snapshots__/piecewise-linear-source-waveforms-schematic.snap.svg b/tests/features/spice-analysis/__snapshots__/piecewise-linear-source-waveforms-schematic.snap.svg new file mode 100644 index 000000000..b1aa5e443 --- /dev/null +++ b/tests/features/spice-analysis/__snapshots__/piecewise-linear-source-waveforms-schematic.snap.svg @@ -0,0 +1,12 @@ +-1,-2-1,-1-1,0-1,1-1,20,-20,-10,00,10,21,-21,-11,01,11,22,-22,-12,02,12,23,-23,-13,03,13,24,-24,-14,04,14,2VIN2.2VILOAD100mAGNDVOUT \ No newline at end of file diff --git a/tests/features/spice-analysis/analog-multidimensional-parameter-sweep.test.tsx b/tests/features/spice-analysis/analog-multidimensional-parameter-sweep.test.tsx new file mode 100644 index 000000000..4bb1d10e4 --- /dev/null +++ b/tests/features/spice-analysis/analog-multidimensional-parameter-sweep.test.tsx @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test" +import type { SpiceEngine } from "@tscircuit/props" +import { analog } from "lib" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("multiple parameter sweeps execute their Cartesian product in child order", async () => { + const spiceStrings: string[] = [] + const fakeEngine: SpiceEngine = { + async simulate(spiceString) { + spiceStrings.push(spiceString) + return { + simulationResultCircuitJson: [ + { + type: "simulation_dc_operating_point_voltage", + simulation_dc_operating_point_voltage_id: "result", + simulation_experiment_id: "fake", + simulation_voltage_probe_id: "fake_probe", + name: "VOUT", + voltage: spiceStrings.length, + }, + ], + } + }, + } + const { circuit } = getTestFixture({ + platform: { spiceEngineMap: { fake: fakeEngine } }, + }) + + circuit.add( + + + + + + + + + + + + + + + + + + , + ) + + await circuit.renderUntilSettled() + + expect( + spiceStrings.map((spiceString) => [ + Number(spiceString.match(/^RR1 .+ (\d+)$/m)?.[1]), + spiceString.match(/^CC1 .+ (\w+)$/m)?.[1], + ]), + ).toEqual([ + [100, "1U"], + [100, "2U"], + [100, "3U"], + [200, "1U"], + [200, "2U"], + [200, "3U"], + ]) + expect( + circuit.db.simulation_dc_operating_point_voltage + .list() + .map((result) => + result.simulation_parameter_sweep_coordinates?.map( + (coordinate) => coordinate.parameter_value, + ), + ), + ).toEqual([ + [100, 1e-6], + [100, 2e-6], + [100, 3e-6], + [200, 1e-6], + [200, 2e-6], + [200, 3e-6], + ]) + await expect(circuit).toMatchSimulationSnapshot(import.meta.path) +}) diff --git a/tests/features/spice-analysis/analog-transient-measurements.test.tsx b/tests/features/spice-analysis/analog-transient-measurements.test.tsx new file mode 100644 index 000000000..bdf15af9a --- /dev/null +++ b/tests/features/spice-analysis/analog-transient-measurements.test.tsx @@ -0,0 +1,201 @@ +import { expect, test } from "bun:test" +import type { SpiceEngine } from "@tscircuit/props" +import { analog } from "lib" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("transient measurements read voltage and current results for every sweep run", async () => { + let runCount = 0 + const fakeEngine: SpiceEngine = { + async simulate(spiceString) { + runCount++ + const voltageProbeMappings = spiceString + .split("\n") + .filter((line) => line.startsWith("* tscircuit_probe ")) + .map( + (line) => + JSON.parse(line.slice("* tscircuit_probe ".length)) as { + simulation_voltage_probe_id: string + name: string + }, + ) + return { + simulationResultCircuitJson: [ + ...voltageProbeMappings.map((probe) => ({ + type: "simulation_transient_voltage_graph", + simulation_transient_voltage_graph_id: `voltage_result_${probe.simulation_voltage_probe_id}`, + simulation_experiment_id: "fake", + name: probe.name, + source_probe_id: probe.simulation_voltage_probe_id, + timestamps_ms: [0, 1, 2], + voltage_levels: [3.2, 3.25, 3.3 + runCount / 100], + time_per_step: 1, + start_time_ms: 0, + end_time_ms: 2, + })), + { + type: "simulation_transient_current_graph", + simulation_transient_current_graph_id: "current_result", + simulation_experiment_id: "fake", + name: "IIN", + timestamps_ms: [0, 1, 2], + current_levels: [0.1, 0.2, runCount], + time_per_step: 1, + start_time_ms: 0, + end_time_ms: 2, + }, + ], + } + }, + } + const { circuit } = getTestFixture({ + platform: { spiceEngineMap: { fake: fakeEngine } }, + }) + + circuit.add( + + + + + .pin2", neg: "net.VOUT" }} + /> + + + + + + + + getVoltage("net.VOUT").values.at(-1) ?? Number.NaN + } + /> + + getCurrent(".IIN").values.at(-1) ?? Number.NaN + } + /> + + , + ) + + await circuit.renderUntilSettled() + + const measurementResults = circuit.db.simulation_measurement_result.list() + expect(measurementResults).toHaveLength(2) + expect( + measurementResults[0]?.measurement_values.map((value) => + Number(value.toFixed(2)), + ), + ).toEqual([3.31, 3.32]) + expect(measurementResults[1]?.measurement_values).toEqual([1, 2]) + expect( + measurementResults[0]?.simulation_parameter_sweep_coordinate_sets?.map( + (coordinateSet) => coordinateSet[0]?.parameter_value, + ), + ).toEqual([1000, 2000]) + expect(circuit.db.simulation_transient_voltage_graph.list()).toHaveLength(0) + await expect(circuit).toMatchSimulationSnapshot(import.meta.path) +}) + +test("explicit voltage probes are not shadowed by implicit measurement probes", async () => { + let voltageProbeMappings: Array<{ + simulation_voltage_probe_id: string + name: string + spice_vector: string + }> = [] + const fakeEngine: SpiceEngine = { + async simulate(spiceString) { + voltageProbeMappings = spiceString + .split("\n") + .filter((line) => line.startsWith("* tscircuit_probe ")) + .map( + (line) => + JSON.parse(line.slice("* tscircuit_probe ".length)) as { + simulation_voltage_probe_id: string + name: string + spice_vector: string + }, + ) + const probeBySpiceVector = new Map( + voltageProbeMappings.map((probe) => [probe.spice_vector, probe]), + ) + + return { + simulationResultCircuitJson: [...probeBySpiceVector.values()].map( + (probe) => ({ + type: "simulation_transient_voltage_graph", + simulation_transient_voltage_graph_id: `voltage_result_${probe.simulation_voltage_probe_id}`, + simulation_experiment_id: "fake", + name: probe.name, + source_probe_id: probe.simulation_voltage_probe_id, + timestamps_ms: [0, 1], + voltage_levels: [3.3, 3.3], + time_per_step: 1, + start_time_ms: 0, + end_time_ms: 1, + }), + ), + } + }, + } + const { circuit } = getTestFixture({ + platform: { spiceEngineMap: { fake: fakeEngine } }, + }) + + circuit.add( + + + + + + + + + getVoltage(".VIN > .pin1").values.at(-1) ?? Number.NaN + } + /> + + , + ) + + await circuit.renderUntilSettled() + + const outputVoltageProbeId = + circuit.db.simulation_voltage_probe.list()[0]?.simulation_voltage_probe_id + const outputVoltageProbeMapping = voltageProbeMappings.find( + (probe) => probe.simulation_voltage_probe_id === outputVoltageProbeId, + ) + expect(outputVoltageProbeMapping).toBeDefined() + expect( + voltageProbeMappings.filter( + (probe) => probe.spice_vector === outputVoltageProbeMapping?.spice_vector, + ), + ).toHaveLength(1) + expect( + circuit.db.simulation_measurement_result.list()[0]?.measurement_values, + ).toEqual([3.3]) + expect(circuit.db.simulation_unknown_experiment_error.list()).toHaveLength(0) +}) diff --git a/tests/features/spice-analysis/piecewise-linear-source-waveforms.test.tsx b/tests/features/spice-analysis/piecewise-linear-source-waveforms.test.tsx new file mode 100644 index 000000000..0a8f83adc --- /dev/null +++ b/tests/features/spice-analysis/piecewise-linear-source-waveforms.test.tsx @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test" +import type { SpiceEngine } from "@tscircuit/props" +import { analog } from "lib" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("piecewise-linear source props emit Circuit JSON and SPICE PWL sources", async () => { + let capturedSpice = "" + const fakeEngine: SpiceEngine = { + async simulate(spiceString) { + capturedSpice = spiceString + return { simulationResultCircuitJson: [] } + }, + } + const { circuit } = getTestFixture({ + platform: { spiceEngineMap: { fake: fakeEngine } }, + }) + + circuit.add( + + + + + + + + + , + ) + + await circuit.renderUntilSettled() + + const voltageSource = circuit.db.simulation_voltage_source + .list() + .find((source) => !source.is_dc_source) + const currentSource = circuit.db.simulation_current_source + .list() + .find((source) => !source.is_dc_source) + expect(voltageSource?.voltage_waveform).toEqual({ + timestamps_ms: [0, 1, 1.001], + voltage_values: [2.2, 2.2, 4.2], + }) + expect(currentSource?.current_waveform).toEqual({ + timestamps_ms: [0, 1, 1.001], + current_values: [0.1, 0.1, 1], + }) + expect(capturedSpice).toContain("PWL(0 2.2 1m 2.2 1.001m 4.2)") + expect(capturedSpice).toContain("PWL(0 0.1 1m 0.1 1.001m 1)") + await expect(circuit).toMatchSchematicSnapshot(import.meta.path) +}) From 7a2a4c5296979e6c5687c316b1a0248842f9435b Mon Sep 17 00:00:00 2001 From: ShiboSoftwareDev Date: Thu, 30 Jul 2026 10:02:17 +0200 Subject: [PATCH 2/3] Run SPICE parameter sweeps concurrently --- ...up_doInitialSimulationSpiceEngineRender.ts | 19 +++-- .../Group/run-spice-simulations.ts | 58 +++++++++++++++ ...urrent-parameter-sweep-simulation.snap.svg | 44 +++++++++++ ...analog-concurrent-parameter-sweep.test.tsx | 73 +++++++++++++++++++ 4 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 lib/components/primitive-components/Group/run-spice-simulations.ts create mode 100644 tests/features/spice-analysis/__snapshots__/analog-concurrent-parameter-sweep-simulation.snap.svg create mode 100644 tests/features/spice-analysis/analog-concurrent-parameter-sweep.test.tsx diff --git a/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts b/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts index 4dd2307ef..32fae9f71 100644 --- a/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts +++ b/lib/components/primitive-components/Group/Group_doInitialSimulationSpiceEngineRender.ts @@ -36,6 +36,7 @@ import { isCurrentGraph } from "./isCurrentGraph" import { isSimulationCurrentResult } from "./isSimulationCurrentResult" import { isSimulationVoltageResult } from "./isSimulationVoltageResult" import { isVoltageGraph } from "./isVoltageGraph" +import { runSpiceSimulations } from "./run-spice-simulations" type AnalogSimulationComponent = | AnalogSimulation @@ -336,12 +337,20 @@ export function Group_doInitialSimulationSpiceEngineRender(group: Group) { ]), ) - for (const simulationRun of simulationRuns) { + const simulationResults = await runSpiceSimulations({ + spiceEngine, + simulationRuns, + }) + + for (const [ + simulationRunIndex, + simulationRun, + ] of simulationRuns.entries()) { const insertedSimulationGraphsForRun: InsertedSimulationGraph[] = [] - debug(`Running simulation with engine: ${engineName}`) - const simulationResult = await spiceEngine.simulate( - simulationRun.spiceString, - ) + const simulationResult = simulationResults[simulationRunIndex] + if (!simulationResult) { + throw new Error("SPICE simulation result is missing") + } debug( `Simulation completed, received ${simulationResult.simulationResultCircuitJson.length} elements`, diff --git a/lib/components/primitive-components/Group/run-spice-simulations.ts b/lib/components/primitive-components/Group/run-spice-simulations.ts new file mode 100644 index 000000000..9d49e9f38 --- /dev/null +++ b/lib/components/primitive-components/Group/run-spice-simulations.ts @@ -0,0 +1,58 @@ +import type { SpiceEngine, SpiceEngineSimulationResult } from "@tscircuit/props" +import type { SimulationRun } from "./createSimulationRuns" + +const getSimulationConcurrency = ({ + maxConcurrentSimulations, + simulationRunCount, +}: { + maxConcurrentSimulations: number | undefined + simulationRunCount: number +}) => { + if ( + maxConcurrentSimulations === undefined || + !Number.isSafeInteger(maxConcurrentSimulations) || + maxConcurrentSimulations < 1 + ) { + return 1 + } + return Math.min(maxConcurrentSimulations, simulationRunCount) +} + +export const runSpiceSimulations = async ({ + spiceEngine, + simulationRuns, +}: { + spiceEngine: SpiceEngine + simulationRuns: SimulationRun[] +}): Promise => { + const simulationResults: SpiceEngineSimulationResult[] = new Array( + simulationRuns.length, + ) + const maxConcurrentSimulations = + "maxConcurrentSimulations" in spiceEngine && + typeof spiceEngine.maxConcurrentSimulations === "number" + ? spiceEngine.maxConcurrentSimulations + : undefined + const simulationConcurrency = getSimulationConcurrency({ + maxConcurrentSimulations, + simulationRunCount: simulationRuns.length, + }) + let nextSimulationRunIndex = 0 + + await Promise.all( + Array.from({ length: simulationConcurrency }, async () => { + while (nextSimulationRunIndex < simulationRuns.length) { + const simulationRunIndex = nextSimulationRunIndex++ + const simulationRun = simulationRuns[simulationRunIndex] + if (!simulationRun) { + throw new Error("SPICE simulation run is missing") + } + simulationResults[simulationRunIndex] = await spiceEngine.simulate( + simulationRun.spiceString, + ) + } + }), + ) + + return simulationResults +} diff --git a/tests/features/spice-analysis/__snapshots__/analog-concurrent-parameter-sweep-simulation.snap.svg b/tests/features/spice-analysis/__snapshots__/analog-concurrent-parameter-sweep-simulation.snap.svg new file mode 100644 index 000000000..01acce2e5 --- /dev/null +++ b/tests/features/spice-analysis/__snapshots__/analog-concurrent-parameter-sweep-simulation.snap.svg @@ -0,0 +1,44 @@ +V15VR11kΩGNDGNDVOUT-1010100200300400500Voltage (V)Operating Pointsimulation_dcoperating_pointvoltage0 (100Ω)simulation_dcoperating_pointvoltage1 (200Ω)simulation_dcoperating_pointvoltage2 (300Ω)simulation_dcoperating_pointvoltage3 (400Ω)swept-op \ No newline at end of file diff --git a/tests/features/spice-analysis/analog-concurrent-parameter-sweep.test.tsx b/tests/features/spice-analysis/analog-concurrent-parameter-sweep.test.tsx new file mode 100644 index 000000000..f9b9dfcd3 --- /dev/null +++ b/tests/features/spice-analysis/analog-concurrent-parameter-sweep.test.tsx @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test" +import type { SpiceEngine } from "@tscircuit/props" +import { analog } from "lib" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("analog parameter sweeps honor engine simulation concurrency", async () => { + let activeSimulationCount = 0 + let maximumActiveSimulationCount = 0 + const completionOrder: number[] = [] + const fakeEngine: SpiceEngine & { maxConcurrentSimulations: number } = { + maxConcurrentSimulations: 2, + async simulate(spiceString) { + const resistance = Number(spiceString.match(/^RR1 .+ (\d+)$/m)?.[1]) + activeSimulationCount++ + maximumActiveSimulationCount = Math.max( + maximumActiveSimulationCount, + activeSimulationCount, + ) + await Bun.sleep(resistance === 100 ? 400 : 50) + completionOrder.push(resistance) + activeSimulationCount-- + return { + simulationResultCircuitJson: [ + { + type: "simulation_dc_operating_point_voltage", + simulation_dc_operating_point_voltage_id: "result", + simulation_experiment_id: "fake", + simulation_voltage_probe_id: "fake_probe", + voltage: resistance, + }, + ], + } + }, + } + const { circuit } = getTestFixture({ + platform: { spiceEngineMap: { fake: fakeEngine } }, + }) + + circuit.add( + + + + + + + + + + , + ) + + await circuit.renderUntilSettled() + + expect(maximumActiveSimulationCount).toBe(2) + expect(completionOrder).toEqual([200, 300, 400, 100]) + expect( + circuit.db.simulation_dc_operating_point_voltage + .list() + .map((result) => result.voltage), + ).toEqual([100, 200, 300, 400]) + expect(circuit.db.simulation_unknown_experiment_error.list()).toHaveLength(0) + await expect(circuit).toMatchSimulationSnapshot(import.meta.path) +}) From 367d9570dea7ec5a19d504e19e03cf93ea6dc43a Mon Sep 17 00:00:00 2001 From: ShiboSoftwareDev Date: Fri, 31 Jul 2026 12:25:26 +0200 Subject: [PATCH 3/3] Update analog sweep snapshots --- .../analog-concurrent-parameter-sweep-simulation.snap.svg | 4 ++-- ...log-multidimensional-parameter-sweep-simulation.snap.svg | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/features/spice-analysis/__snapshots__/analog-concurrent-parameter-sweep-simulation.snap.svg b/tests/features/spice-analysis/__snapshots__/analog-concurrent-parameter-sweep-simulation.snap.svg index 01acce2e5..b1912ab3c 100644 --- a/tests/features/spice-analysis/__snapshots__/analog-concurrent-parameter-sweep-simulation.snap.svg +++ b/tests/features/spice-analysis/__snapshots__/analog-concurrent-parameter-sweep-simulation.snap.svg @@ -8,7 +8,7 @@ .pin-number { fill: rgb(169, 0, 0); } .port-label { fill: rgb(0, 100, 100); } .component-name { fill: rgb(0, 100, 100); } - + V15VR11kΩ-1010100200300400500Voltage (V)Operating Pointsimulation_dcoperating_pointvoltage0 (100Ω)simulation_dcoperating_pointvoltage1 (200Ω)simulation_dcoperating_pointvoltage2 (300Ω)simulation_dcoperating_pointvoltage3 (400Ω)swept-op \ No newline at end of file +-1010100200300400500Voltage (V)Operating Pointsimulation_dcoperating_pointvoltage0 (100Ω)simulation_dcoperating_pointvoltage1 (200Ω)simulation_dcoperating_pointvoltage2 (300Ω)simulation_dcoperating_pointvoltage3 (400Ω)swept-op diff --git a/tests/features/spice-analysis/__snapshots__/analog-multidimensional-parameter-sweep-simulation.snap.svg b/tests/features/spice-analysis/__snapshots__/analog-multidimensional-parameter-sweep-simulation.snap.svg index 870887bfb..7add0e6f1 100644 --- a/tests/features/spice-analysis/__snapshots__/analog-multidimensional-parameter-sweep-simulation.snap.svg +++ b/tests/features/spice-analysis/__snapshots__/analog-multidimensional-parameter-sweep-simulation.snap.svg @@ -1,4 +1,4 @@ --10101234567Voltage (V)Operating PointVOUT (100Ω, 0.000001F)VOUT (100Ω, 0.000002F)VOUT (100Ω, 0.000003F)VOUT (200Ω, 0.000001F)VOUT (200Ω, 0.000002F)VOUT (200Ω, 0.000003F)sweep