circuitjson-toolkit 1.0.2 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,421 @@
1
+ import { SpiceDirectiveParser } from './SpiceDirectiveParser.mjs'
2
+ import { SpiceTimeSeriesNormalizer } from './SpiceTimeSeriesNormalizer.mjs'
3
+
4
+ /**
5
+ * Converts simulator result rows into CircuitJSON transient graph elements.
6
+ */
7
+ export class SpiceSimulationGraphBuilder {
8
+ /**
9
+ * Builds CircuitJSON transient graph elements from a simulation result.
10
+ * @param {object} result Simulator result.
11
+ * @param {string} spiceString Preprocessed SPICE netlist text.
12
+ * @param {{ simulationExperimentId?: string }} [options] Graph output options.
13
+ * @returns {object[]}
14
+ */
15
+ static buildCircuitJsonGraphs(result, spiceString, options = {}) {
16
+ const tran = SpiceDirectiveParser.parseTransient(spiceString)
17
+ const graphs = SpiceTimeSeriesNormalizer.resampleGraphs(
18
+ SpiceSimulationGraphBuilder.#buildGraphs(result, spiceString),
19
+ tran
20
+ )
21
+ const simulationExperimentId =
22
+ options.simulationExperimentId || 'simulation_experiment_0'
23
+
24
+ return graphs.map((graph, index) =>
25
+ graph.graphType === 'voltage'
26
+ ? SpiceSimulationGraphBuilder.#voltageGraphElement(
27
+ graph,
28
+ index,
29
+ tran,
30
+ simulationExperimentId
31
+ )
32
+ : SpiceSimulationGraphBuilder.#currentGraphElement(
33
+ graph,
34
+ index,
35
+ tran,
36
+ simulationExperimentId
37
+ )
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Builds a complete simulation experiment element set.
43
+ * @param {object} result Simulator result.
44
+ * @param {string} spiceString Preprocessed SPICE netlist text.
45
+ * @param {{ simulationExperimentId?: string, name?: string }} [options] Experiment output options.
46
+ * @returns {object[]}
47
+ */
48
+ static buildCircuitJsonExperiment(result, spiceString, options = {}) {
49
+ const simulationExperimentId =
50
+ options.simulationExperimentId || 'simulation_experiment_0'
51
+ const graphs = SpiceSimulationGraphBuilder.buildCircuitJsonGraphs(
52
+ result,
53
+ spiceString,
54
+ { simulationExperimentId }
55
+ )
56
+
57
+ return [
58
+ {
59
+ type: 'simulation_experiment',
60
+ simulation_experiment_id: simulationExperimentId,
61
+ name: options.name || 'SPICE transient analysis',
62
+ experiment_type: 'spice_transient_analysis'
63
+ },
64
+ ...graphs
65
+ ]
66
+ }
67
+
68
+ /**
69
+ * Finds requested transient plot tokens that did not produce graph models.
70
+ * @param {object} result Simulator result.
71
+ * @param {string} spiceString Preprocessed SPICE netlist text.
72
+ * @returns {{ normalizedToken: string, originalToken: string }[]}
73
+ */
74
+ static findMissingRequestedPlots(result, spiceString) {
75
+ const requestedPlots =
76
+ SpiceDirectiveParser.parseRequestedPlots(spiceString)
77
+ if (!requestedPlots) return []
78
+
79
+ const fulfilledTokens = new Set(
80
+ SpiceSimulationGraphBuilder.#buildGraphs(result, spiceString)
81
+ .map((graph) => graph.normalizedToken)
82
+ .filter(Boolean)
83
+ )
84
+
85
+ return [...requestedPlots]
86
+ .filter(
87
+ ([normalizedToken]) => !fulfilledTokens.has(normalizedToken)
88
+ )
89
+ .map(([normalizedToken, originalToken]) => ({
90
+ normalizedToken,
91
+ originalToken
92
+ }))
93
+ }
94
+
95
+ /**
96
+ * Builds normalized graph models from simulator data rows.
97
+ * @param {object} result Simulator result.
98
+ * @param {string} spiceString Preprocessed SPICE netlist text.
99
+ * @returns {object[]}
100
+ */
101
+ static #buildGraphs(result, spiceString) {
102
+ if (!result?.data || result.dataType !== 'real') return []
103
+
104
+ const timeRow = result.data.find((row) => row.type === 'time')
105
+ if (!Array.isArray(timeRow?.values)) return []
106
+
107
+ const timeValues = timeRow.values
108
+ const voltageRows = result.data.filter(
109
+ (row) => row.type === 'voltage' && Array.isArray(row.values)
110
+ )
111
+ const currentRows = result.data.filter(
112
+ (row) => row.type === 'current' && Array.isArray(row.values)
113
+ )
114
+ const voltageData = new Map(
115
+ voltageRows.map((row) => [
116
+ SpiceDirectiveParser.normalizeVector(row.name),
117
+ row.values
118
+ ])
119
+ )
120
+ const currentData = new Map(
121
+ currentRows.map((row) => [
122
+ SpiceDirectiveParser.normalizeVector(row.name),
123
+ row.values
124
+ ])
125
+ )
126
+ const requestedPlots =
127
+ SpiceDirectiveParser.parseRequestedPlots(spiceString)
128
+ const voltageMetadata =
129
+ SpiceDirectiveParser.extractVoltageProbeMetadata(spiceString)
130
+ const currentMetadata =
131
+ SpiceDirectiveParser.extractCurrentProbeMetadata(spiceString)
132
+
133
+ if (!requestedPlots) {
134
+ return [
135
+ ...voltageRows.map((row) =>
136
+ SpiceSimulationGraphBuilder.#voltageGraphFromRow(
137
+ row,
138
+ timeValues,
139
+ voltageMetadata
140
+ )
141
+ ),
142
+ ...currentRows.map((row) =>
143
+ SpiceSimulationGraphBuilder.#currentGraphFromRow(
144
+ row,
145
+ timeValues,
146
+ currentMetadata
147
+ )
148
+ )
149
+ ]
150
+ }
151
+
152
+ const graphs = []
153
+ for (const [normalizedToken, originalToken] of requestedPlots) {
154
+ const graph =
155
+ SpiceSimulationGraphBuilder.#voltageGraphFromPlot({
156
+ normalizedToken,
157
+ originalToken,
158
+ timeValues,
159
+ voltageData,
160
+ voltageMetadata
161
+ }) ??
162
+ SpiceSimulationGraphBuilder.#currentGraphFromPlot({
163
+ normalizedToken,
164
+ originalToken,
165
+ timeValues,
166
+ currentData,
167
+ currentMetadata
168
+ })
169
+
170
+ if (graph) graphs.push(graph)
171
+ }
172
+
173
+ return graphs
174
+ }
175
+
176
+ /**
177
+ * Builds one voltage graph from an available result row.
178
+ * @param {object} row Simulator data row.
179
+ * @param {number[]} timeValues Time values in seconds.
180
+ * @param {Map<string, object>} voltageMetadata Probe metadata by vector.
181
+ * @returns {object}
182
+ */
183
+ static #voltageGraphFromRow(row, timeValues, voltageMetadata) {
184
+ const normalized = SpiceDirectiveParser.normalizeVector(row.name)
185
+ const metadata = voltageMetadata.get(normalized)
186
+
187
+ return {
188
+ graphType: 'voltage',
189
+ name:
190
+ metadata?.name ??
191
+ SpiceSimulationGraphBuilder.#voltageName(row.name),
192
+ time: timeValues,
193
+ values: row.values,
194
+ metadata
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Builds one current graph from an available result row.
200
+ * @param {object} row Simulator data row.
201
+ * @param {number[]} timeValues Time values in seconds.
202
+ * @param {Map<string, object>} currentMetadata Probe metadata by vector.
203
+ * @returns {object}
204
+ */
205
+ static #currentGraphFromRow(row, timeValues, currentMetadata) {
206
+ const normalized = SpiceDirectiveParser.normalizeVector(row.name)
207
+ const metadata = currentMetadata.get(normalized)
208
+
209
+ return {
210
+ graphType: 'current',
211
+ name:
212
+ metadata?.name ??
213
+ SpiceSimulationGraphBuilder.#currentName(row.name),
214
+ time: timeValues,
215
+ values: row.values,
216
+ metadata
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Builds one voltage graph for a requested plot token.
222
+ * @param {object} options Plot options.
223
+ * @returns {object | null}
224
+ */
225
+ static #voltageGraphFromPlot(options) {
226
+ const {
227
+ normalizedToken,
228
+ originalToken,
229
+ timeValues,
230
+ voltageData,
231
+ voltageMetadata
232
+ } = options
233
+ if (!normalizedToken.startsWith('v(')) return null
234
+
235
+ const diffMatch = originalToken.match(/^v\(([^,]+),\s*([^)]+)\)$/i)
236
+ let values = voltageData.get(normalizedToken)
237
+
238
+ if (!values && diffMatch?.[1] && diffMatch?.[2]) {
239
+ values = SpiceSimulationGraphBuilder.#differentialValues(
240
+ diffMatch[1],
241
+ diffMatch[2],
242
+ voltageData
243
+ )
244
+ }
245
+
246
+ if (!values) return null
247
+
248
+ const metadata = voltageMetadata.get(normalizedToken)
249
+ return {
250
+ graphType: 'voltage',
251
+ normalizedToken,
252
+ name:
253
+ metadata?.name ??
254
+ SpiceSimulationGraphBuilder.#voltageName(originalToken),
255
+ time: timeValues,
256
+ values,
257
+ metadata
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Builds one current graph for a requested plot token.
263
+ * @param {object} options Plot options.
264
+ * @returns {object | null}
265
+ */
266
+ static #currentGraphFromPlot(options) {
267
+ const {
268
+ normalizedToken,
269
+ originalToken,
270
+ timeValues,
271
+ currentData,
272
+ currentMetadata
273
+ } = options
274
+ if (!normalizedToken.startsWith('i(')) return null
275
+
276
+ const values = currentData.get(normalizedToken)
277
+ if (!values) return null
278
+
279
+ const metadata = currentMetadata.get(normalizedToken)
280
+ return {
281
+ graphType: 'current',
282
+ normalizedToken,
283
+ name:
284
+ metadata?.name ??
285
+ SpiceSimulationGraphBuilder.#currentName(originalToken),
286
+ time: timeValues,
287
+ values,
288
+ metadata
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Resolves differential voltage values from two node vectors.
294
+ * @param {string} positiveNode Positive node name.
295
+ * @param {string} referenceNode Reference node name.
296
+ * @param {Map<string, number[]>} voltageData Voltage data map.
297
+ * @returns {number[] | undefined}
298
+ */
299
+ static #differentialValues(positiveNode, referenceNode, voltageData) {
300
+ const positiveValues = voltageData.get(
301
+ `v(${String(positiveNode).trim().toLowerCase()})`
302
+ )
303
+ const referenceValues = voltageData.get(
304
+ `v(${String(referenceNode).trim().toLowerCase()})`
305
+ )
306
+
307
+ if (!positiveValues || !referenceValues) return undefined
308
+
309
+ return positiveValues.map((value, index) =>
310
+ SpiceSimulationGraphBuilder.#round(
311
+ value - (referenceValues[index] ?? 0)
312
+ )
313
+ )
314
+ }
315
+
316
+ /**
317
+ * Converts a normalized graph to a voltage graph element.
318
+ * @param {object} graph Graph model.
319
+ * @param {number} index Graph index.
320
+ * @param {object | null} tran Transient timing parameters.
321
+ * @param {string} simulationExperimentId Simulation experiment id.
322
+ * @returns {object}
323
+ */
324
+ static #voltageGraphElement(graph, index, tran, simulationExperimentId) {
325
+ const graphIdSource =
326
+ graph.metadata?.simulation_voltage_probe_id ??
327
+ `${index}_${graph.name}`
328
+
329
+ return {
330
+ type: 'simulation_transient_voltage_graph',
331
+ simulation_experiment_id: simulationExperimentId,
332
+ simulation_transient_voltage_graph_id:
333
+ 'simulation_graph_' + graphIdSource,
334
+ name: graph.name,
335
+ voltage_levels: graph.values.map((value) =>
336
+ SpiceSimulationGraphBuilder.#round(value)
337
+ ),
338
+ timestamps_ms: graph.time.map((time) =>
339
+ SpiceSimulationGraphBuilder.#round(time * 1000)
340
+ ),
341
+ start_time_ms: (tran?.tstart ?? 0) * 1000,
342
+ time_per_step: (tran?.tstep ?? 0) * 1000,
343
+ end_time_ms: (tran?.tstop ?? 0) * 1000,
344
+ source_probe_id: graph.metadata?.simulation_voltage_probe_id,
345
+ source_probe_name: graph.metadata?.name,
346
+ source_node_name: graph.metadata?.source_node_name,
347
+ reference_node_name: graph.metadata?.reference_node_name
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Converts a normalized graph to a current graph element.
353
+ * @param {object} graph Graph model.
354
+ * @param {number} index Graph index.
355
+ * @param {object | null} tran Transient timing parameters.
356
+ * @param {string} simulationExperimentId Simulation experiment id.
357
+ * @returns {object}
358
+ */
359
+ static #currentGraphElement(graph, index, tran, simulationExperimentId) {
360
+ const graphIdSource =
361
+ graph.metadata?.simulation_current_probe_id ??
362
+ `${index}_${graph.name}`
363
+
364
+ return {
365
+ type: 'simulation_transient_current_graph',
366
+ simulation_experiment_id: simulationExperimentId,
367
+ simulation_transient_current_graph_id:
368
+ 'simulation_graph_' + graphIdSource,
369
+ name: graph.name,
370
+ current_levels: graph.values.map((value) =>
371
+ SpiceSimulationGraphBuilder.#round(value)
372
+ ),
373
+ timestamps_ms: graph.time.map((time) =>
374
+ SpiceSimulationGraphBuilder.#round(time * 1000)
375
+ ),
376
+ start_time_ms: (tran?.tstart ?? 0) * 1000,
377
+ time_per_step: (tran?.tstep ?? 0) * 1000,
378
+ end_time_ms: (tran?.tstop ?? 0) * 1000,
379
+ source_probe_id: graph.metadata?.simulation_current_probe_id,
380
+ source_probe_name: graph.metadata?.name,
381
+ source_component_id: graph.metadata?.source_component_id,
382
+ source_trace_id: graph.metadata?.source_trace_id
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Returns a display name for a voltage vector token.
388
+ * @param {string} rawName Raw vector token.
389
+ * @returns {string}
390
+ */
391
+ static #voltageName(rawName) {
392
+ const diffMatch = String(rawName || '').match(
393
+ /^v\(([^,]+),\s*([^)]+)\)$/i
394
+ )
395
+ if (diffMatch?.[1] && diffMatch?.[2]) {
396
+ return `${diffMatch[1].trim()}-${diffMatch[2].trim()}`
397
+ }
398
+
399
+ const match = String(rawName || '').match(/^v\((.*)\)$/i)
400
+ return match?.[1] ?? String(rawName || '')
401
+ }
402
+
403
+ /**
404
+ * Returns a display name for a current vector token.
405
+ * @param {string} rawName Raw vector token.
406
+ * @returns {string}
407
+ */
408
+ static #currentName(rawName) {
409
+ const match = String(rawName || '').match(/^i\((.*)\)$/i)
410
+ return match?.[1] ?? String(rawName || '')
411
+ }
412
+
413
+ /**
414
+ * Rounds numeric output to a stable precision.
415
+ * @param {number} value Numeric value.
416
+ * @returns {number}
417
+ */
418
+ static #round(value) {
419
+ return Number(Number(value).toPrecision(12))
420
+ }
421
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Builds deterministic summaries for transient graph elements.
3
+ */
4
+ export class SpiceSimulationGraphSummary {
5
+ /**
6
+ * Summarizes transient graph elements for renderer and test callers.
7
+ * @param {object[]} simulationResultCircuitJson Graph-only CircuitJSON elements.
8
+ * @returns {object}
9
+ */
10
+ static summarize(simulationResultCircuitJson) {
11
+ const graphs = Array.isArray(simulationResultCircuitJson)
12
+ ? simulationResultCircuitJson.filter((element) =>
13
+ SpiceSimulationGraphSummary.#isTransientGraph(element)
14
+ )
15
+ : []
16
+
17
+ return {
18
+ graphCount: graphs.length,
19
+ voltageGraphCount: graphs.filter(
20
+ (element) =>
21
+ element.type === 'simulation_transient_voltage_graph'
22
+ ).length,
23
+ currentGraphCount: graphs.filter(
24
+ (element) =>
25
+ element.type === 'simulation_transient_current_graph'
26
+ ).length,
27
+ graphs: graphs.map((element) =>
28
+ SpiceSimulationGraphSummary.#summarizeGraph(element)
29
+ )
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Returns true when an element is a transient graph.
35
+ * @param {object} element Candidate CircuitJSON element.
36
+ * @returns {boolean}
37
+ */
38
+ static #isTransientGraph(element) {
39
+ return (
40
+ element?.type === 'simulation_transient_voltage_graph' ||
41
+ element?.type === 'simulation_transient_current_graph'
42
+ )
43
+ }
44
+
45
+ /**
46
+ * Summarizes one transient graph element.
47
+ * @param {object} element Transient graph element.
48
+ * @returns {object}
49
+ */
50
+ static #summarizeGraph(element) {
51
+ const isVoltage = element.type === 'simulation_transient_voltage_graph'
52
+ const values = isVoltage
53
+ ? element.voltage_levels
54
+ : element.current_levels
55
+ const finiteValues = Array.isArray(values)
56
+ ? values.filter(Number.isFinite)
57
+ : []
58
+
59
+ return {
60
+ id: isVoltage
61
+ ? element.simulation_transient_voltage_graph_id
62
+ : element.simulation_transient_current_graph_id,
63
+ graphType: isVoltage ? 'voltage' : 'current',
64
+ name: element.name,
65
+ pointCount: Array.isArray(values) ? values.length : 0,
66
+ startTimeMs: element.start_time_ms,
67
+ endTimeMs: element.end_time_ms,
68
+ timePerStepMs: element.time_per_step,
69
+ min: SpiceSimulationGraphSummary.#round(
70
+ finiteValues.length ? Math.min(...finiteValues) : undefined
71
+ ),
72
+ max: SpiceSimulationGraphSummary.#round(
73
+ finiteValues.length ? Math.max(...finiteValues) : undefined
74
+ ),
75
+ firstValue: SpiceSimulationGraphSummary.#round(values?.[0]),
76
+ lastValue: SpiceSimulationGraphSummary.#round(values?.at(-1))
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Rounds finite numeric output to a stable precision.
82
+ * @param {unknown} value Numeric value.
83
+ * @returns {number | undefined}
84
+ */
85
+ static #round(value) {
86
+ return Number.isFinite(value)
87
+ ? Number(Number(value).toPrecision(12))
88
+ : undefined
89
+ }
90
+ }
@@ -0,0 +1,92 @@
1
+ import { CircuitJsonDocument } from '../CircuitJsonDocument.mjs'
2
+ import { SpiceCompatibilityPreprocessor } from './SpiceCompatibilityPreprocessor.mjs'
3
+ import { SpiceFallbackSimulationEngine } from './SpiceFallbackSimulationEngine.mjs'
4
+ import { SpiceSimulationDiagnostics } from './SpiceSimulationDiagnostics.mjs'
5
+ import { SpiceSimulationGraphBuilder } from './SpiceSimulationGraphBuilder.mjs'
6
+ import { SpiceSimulationGraphSummary } from './SpiceSimulationGraphSummary.mjs'
7
+
8
+ /**
9
+ * Runs SPICE transient simulations through an injectable engine boundary.
10
+ */
11
+ export class SpiceSimulationService {
12
+ /** @type {{ simulate: (spiceString: string) => Promise<object> | object }} */
13
+ #engine
14
+
15
+ /**
16
+ * @param {{ engine?: { simulate: (spiceString: string) => Promise<object> | object } }} [dependencies] Service dependencies.
17
+ */
18
+ constructor(dependencies = {}) {
19
+ this.#engine =
20
+ dependencies.engine || new SpiceFallbackSimulationEngine()
21
+ }
22
+
23
+ /**
24
+ * Simulates a netlist with the default local fallback engine.
25
+ * @param {string} spiceString SPICE netlist text.
26
+ * @returns {Promise<{ simulationResultCircuitJson: object[], simulationCircuitJson: object[], graphSummary: object, diagnostics: object[] }>}
27
+ */
28
+ static async simulate(spiceString) {
29
+ return new SpiceSimulationService().simulate(spiceString)
30
+ }
31
+
32
+ /**
33
+ * Simulates a netlist and returns CircuitJSON transient graph elements.
34
+ * @param {string} spiceString SPICE netlist text.
35
+ * @returns {Promise<{ simulationResultCircuitJson: object[], simulationCircuitJson: object[], graphSummary: object, diagnostics: object[] }>}
36
+ */
37
+ async simulate(spiceString) {
38
+ const preprocessedNetlist =
39
+ SpiceCompatibilityPreprocessor.rewrite(spiceString)
40
+ const diagnostics = SpiceSimulationDiagnostics.analyze(spiceString)
41
+
42
+ try {
43
+ const rawResult = await this.#engine.simulate(preprocessedNetlist)
44
+ diagnostics.push(
45
+ ...SpiceSimulationDiagnostics.requestedPlotDiagnostics(
46
+ SpiceSimulationGraphBuilder.findMissingRequestedPlots(
47
+ rawResult,
48
+ preprocessedNetlist
49
+ )
50
+ )
51
+ )
52
+ const simulationCircuitJson =
53
+ SpiceSimulationGraphBuilder.buildCircuitJsonExperiment(
54
+ rawResult,
55
+ preprocessedNetlist
56
+ )
57
+ const simulationResultCircuitJson = simulationCircuitJson.filter(
58
+ (element) =>
59
+ element.type === 'simulation_transient_voltage_graph' ||
60
+ element.type === 'simulation_transient_current_graph'
61
+ )
62
+ const graphSummary = SpiceSimulationGraphSummary.summarize(
63
+ simulationResultCircuitJson
64
+ )
65
+
66
+ CircuitJsonDocument.assertModel(simulationResultCircuitJson)
67
+ CircuitJsonDocument.assertModel(simulationCircuitJson)
68
+
69
+ return {
70
+ simulationResultCircuitJson,
71
+ simulationCircuitJson,
72
+ graphSummary,
73
+ diagnostics
74
+ }
75
+ } catch (error) {
76
+ diagnostics.push({
77
+ severity: 'error',
78
+ message:
79
+ error instanceof Error
80
+ ? error.message
81
+ : 'SPICE simulation failed.'
82
+ })
83
+
84
+ return {
85
+ simulationResultCircuitJson: [],
86
+ simulationCircuitJson: [],
87
+ graphSummary: SpiceSimulationGraphSummary.summarize([]),
88
+ diagnostics
89
+ }
90
+ }
91
+ }
92
+ }