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,231 @@
1
+ const NUMBER_SUFFIX_MULTIPLIERS = {
2
+ t: 1e12,
3
+ g: 1e9,
4
+ meg: 1e6,
5
+ k: 1e3,
6
+ m: 1e-3,
7
+ ms: 1e-3,
8
+ u: 1e-6,
9
+ us: 1e-6,
10
+ n: 1e-9,
11
+ ns: 1e-9,
12
+ p: 1e-12,
13
+ ps: 1e-12,
14
+ f: 1e-15,
15
+ fs: 1e-15,
16
+ s: 1
17
+ }
18
+ const VOLTAGE_PROBE_COMMENT_PATTERN =
19
+ /^\s*\*\s*(?:ecadforge_voltage_probe|circuitjson_voltage_probe|simulation_voltage_probe)\s+(.+)\s*$/
20
+ const CURRENT_PROBE_COMMENT_PATTERN =
21
+ /^\s*\*\s*(?:ecadforge_current_probe|circuitjson_current_probe|simulation_current_probe)\s+(.+)\s*$/
22
+
23
+ /**
24
+ * Parses SPICE directives and metadata comments used by simulation helpers.
25
+ */
26
+ export class SpiceDirectiveParser {
27
+ /**
28
+ * Parses transient directive timing parameters.
29
+ * @param {string} spiceString SPICE netlist text.
30
+ * @returns {{ tstep?: number, tstop?: number, tstart?: number, tmax?: number, uic?: boolean } | null}
31
+ */
32
+ static parseTransient(spiceString) {
33
+ for (const rawLine of String(spiceString || '').split(/\r?\n/)) {
34
+ const line = rawLine.trim()
35
+ if (!line || line.startsWith('*')) continue
36
+ if (!line.toLowerCase().startsWith('.tran')) continue
37
+
38
+ const [withoutComment = ''] = line.split(';')
39
+ const tokens = withoutComment.split(/\s+/).filter(Boolean)
40
+ const values = []
41
+ let uic = false
42
+
43
+ for (const token of tokens.slice(1)) {
44
+ if (token.toLowerCase() === 'uic') {
45
+ uic = true
46
+ continue
47
+ }
48
+
49
+ const value = SpiceDirectiveParser.parseNumber(token)
50
+ if (value !== undefined) values.push(value)
51
+ }
52
+
53
+ return {
54
+ ...(values[0] !== undefined ? { tstep: values[0] } : {}),
55
+ ...(values[1] !== undefined ? { tstop: values[1] } : {}),
56
+ ...(values[2] !== undefined ? { tstart: values[2] } : {}),
57
+ ...(values[3] !== undefined ? { tmax: values[3] } : {}),
58
+ ...(uic ? { uic: true } : {})
59
+ }
60
+ }
61
+
62
+ return null
63
+ }
64
+
65
+ /**
66
+ * Parses requested transient plot tokens from the first PRINT directive.
67
+ * @param {string} spiceString SPICE netlist text.
68
+ * @returns {Map<string, string> | null}
69
+ */
70
+ static parseRequestedPlots(spiceString) {
71
+ const match = String(spiceString || '').match(/\.print\s+tran\s+(.*)/i)
72
+ if (!match?.[1]) return null
73
+
74
+ const tokens = match[1].match(/[VI]\s*\([^)]+\)/gi)
75
+ if (!tokens) return null
76
+
77
+ const plots = new Map()
78
+ for (const token of tokens) {
79
+ const normalizedToken = SpiceDirectiveParser.normalizeVector(token)
80
+ if (!plots.has(normalizedToken)) {
81
+ plots.set(normalizedToken, token)
82
+ }
83
+ }
84
+
85
+ return plots
86
+ }
87
+
88
+ /**
89
+ * Extracts voltage probe metadata comments from a netlist.
90
+ * @param {string} spiceString SPICE netlist text.
91
+ * @returns {Map<string, object>}
92
+ */
93
+ static extractVoltageProbeMetadata(spiceString) {
94
+ return SpiceDirectiveParser.#extractProbeMetadata(
95
+ spiceString,
96
+ VOLTAGE_PROBE_COMMENT_PATTERN,
97
+ (parsed) => {
98
+ if (
99
+ typeof parsed.simulation_voltage_probe_id !== 'string' ||
100
+ typeof parsed.spice_vector !== 'string' ||
101
+ typeof parsed.source_node_name !== 'string'
102
+ ) {
103
+ return null
104
+ }
105
+
106
+ return {
107
+ simulation_voltage_probe_id:
108
+ parsed.simulation_voltage_probe_id,
109
+ name:
110
+ typeof parsed.name === 'string'
111
+ ? parsed.name
112
+ : undefined,
113
+ spice_vector: parsed.spice_vector,
114
+ source_node_name: parsed.source_node_name,
115
+ reference_node_name:
116
+ typeof parsed.reference_node_name === 'string'
117
+ ? parsed.reference_node_name
118
+ : undefined
119
+ }
120
+ }
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Extracts current probe metadata comments from a netlist.
126
+ * @param {string} spiceString SPICE netlist text.
127
+ * @returns {Map<string, object>}
128
+ */
129
+ static extractCurrentProbeMetadata(spiceString) {
130
+ return SpiceDirectiveParser.#extractProbeMetadata(
131
+ spiceString,
132
+ CURRENT_PROBE_COMMENT_PATTERN,
133
+ (parsed) => {
134
+ if (
135
+ typeof parsed.simulation_current_probe_id !== 'string' ||
136
+ typeof parsed.spice_vector !== 'string'
137
+ ) {
138
+ return null
139
+ }
140
+
141
+ return {
142
+ simulation_current_probe_id:
143
+ parsed.simulation_current_probe_id,
144
+ name:
145
+ typeof parsed.name === 'string'
146
+ ? parsed.name
147
+ : undefined,
148
+ spice_vector: parsed.spice_vector,
149
+ source_component_id:
150
+ typeof parsed.source_component_id === 'string'
151
+ ? parsed.source_component_id
152
+ : undefined,
153
+ source_trace_id:
154
+ typeof parsed.source_trace_id === 'string'
155
+ ? parsed.source_trace_id
156
+ : undefined
157
+ }
158
+ }
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Normalizes a simulator vector token for map lookups.
164
+ * @param {string} value Raw vector token.
165
+ * @returns {string}
166
+ */
167
+ static normalizeVector(value) {
168
+ return String(value || '')
169
+ .toLowerCase()
170
+ .replace(/\s/g, '')
171
+ }
172
+
173
+ /**
174
+ * Parses a SPICE numeric token with common suffix multipliers.
175
+ * @param {string} token Numeric token.
176
+ * @returns {number | undefined}
177
+ */
178
+ static parseNumber(token) {
179
+ const normalized = String(token || '')
180
+ .replace(/[,]/g, '')
181
+ .toLowerCase()
182
+ const match = normalized.match(
183
+ /^([+-]?\d*\.?\d+(?:e[+-]?\d+)?)([a-z]+)?$/i
184
+ )
185
+
186
+ if (!match) return undefined
187
+
188
+ const base = Number.parseFloat(match[1] || '')
189
+ if (!Number.isFinite(base)) return undefined
190
+
191
+ const suffix = match[2] || ''
192
+ if (!suffix) return base
193
+
194
+ const multiplier =
195
+ NUMBER_SUFFIX_MULTIPLIERS[suffix] ??
196
+ NUMBER_SUFFIX_MULTIPLIERS[suffix.replace(/s$/, '')] ??
197
+ 1
198
+
199
+ return base * multiplier
200
+ }
201
+
202
+ /**
203
+ * Extracts JSON probe metadata comments with a validator callback.
204
+ * @param {string} spiceString SPICE netlist text.
205
+ * @param {RegExp} pattern Comment matcher.
206
+ * @param {(parsed: object) => object | null} shapeMetadata Metadata shaper.
207
+ * @returns {Map<string, object>}
208
+ */
209
+ static #extractProbeMetadata(spiceString, pattern, shapeMetadata) {
210
+ const metadata = new Map()
211
+
212
+ for (const line of String(spiceString || '').split(/\r?\n/)) {
213
+ const match = line.match(pattern)
214
+ if (!match?.[1]) continue
215
+
216
+ try {
217
+ const shapedMetadata = shapeMetadata(JSON.parse(match[1]))
218
+ if (!shapedMetadata) continue
219
+
220
+ metadata.set(
221
+ SpiceDirectiveParser.normalizeVector(
222
+ shapedMetadata.spice_vector
223
+ ),
224
+ shapedMetadata
225
+ )
226
+ } catch {}
227
+ }
228
+
229
+ return metadata
230
+ }
231
+ }
@@ -0,0 +1,168 @@
1
+ import { SpiceDirectiveParser } from './SpiceDirectiveParser.mjs'
2
+
3
+ /**
4
+ * Provides a deterministic local fallback for simple transient examples.
5
+ */
6
+ export class SpiceFallbackSimulationEngine {
7
+ /**
8
+ * Simulates constant independent sources from a SPICE netlist.
9
+ * @param {string} spiceString SPICE netlist text.
10
+ * @returns {Promise<object>}
11
+ */
12
+ async simulate(spiceString) {
13
+ const timeValues =
14
+ SpiceFallbackSimulationEngine.#buildTimeValues(spiceString)
15
+ const voltageRows =
16
+ SpiceFallbackSimulationEngine.#buildVoltageRows(spiceString)
17
+ const currentRows =
18
+ SpiceFallbackSimulationEngine.#buildCurrentRows(spiceString)
19
+
20
+ return {
21
+ dataType: 'real',
22
+ data: [
23
+ { name: 'time', type: 'time', values: timeValues },
24
+ ...voltageRows.map((row) => ({
25
+ name: row.name,
26
+ type: 'voltage',
27
+ values: timeValues.map(() => row.value)
28
+ })),
29
+ ...currentRows.map((row) => ({
30
+ name: row.name,
31
+ type: 'current',
32
+ values: timeValues.map(() => row.value)
33
+ }))
34
+ ]
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Builds transient timestamps from the netlist TRAN directive.
40
+ * @param {string} spiceString SPICE netlist text.
41
+ * @returns {number[]}
42
+ */
43
+ static #buildTimeValues(spiceString) {
44
+ const tran = SpiceDirectiveParser.parseTransient(spiceString)
45
+ const tstep = tran?.tstep && tran.tstep > 0 ? tran.tstep : 0.001
46
+ const tstart = tran?.tstart ?? 0
47
+ const tstop = tran?.tstop && tran.tstop >= tstart ? tran.tstop : tstart
48
+ const stepCount = Math.max(0, Math.round((tstop - tstart) / tstep))
49
+
50
+ return Array.from({ length: stepCount + 1 }, (_, index) =>
51
+ SpiceFallbackSimulationEngine.#round(tstart + index * tstep)
52
+ )
53
+ }
54
+
55
+ /**
56
+ * Builds constant voltage result rows from independent DC voltage sources.
57
+ * @param {string} spiceString SPICE netlist text.
58
+ * @returns {{ name: string, value: number }[]}
59
+ */
60
+ static #buildVoltageRows(spiceString) {
61
+ const nodeVoltages = new Map([['0', 0]])
62
+
63
+ for (const line of SpiceFallbackSimulationEngine.#componentLines(
64
+ spiceString
65
+ )) {
66
+ const tokens = line.split(/\s+/).filter(Boolean)
67
+ const name = tokens[0] || ''
68
+ if (!/^v/i.test(name) || tokens.length < 4) continue
69
+
70
+ const positiveNode = tokens[1]
71
+ const negativeNode = tokens[2]
72
+ const value =
73
+ SpiceFallbackSimulationEngine.#parseDcValue(tokens.slice(3)) ??
74
+ 0
75
+ const negativeValue =
76
+ SpiceFallbackSimulationEngine.#lookupNodeValue(
77
+ nodeVoltages,
78
+ negativeNode
79
+ ) ?? 0
80
+
81
+ nodeVoltages.set(
82
+ String(positiveNode).toLowerCase(),
83
+ negativeValue + value
84
+ )
85
+ }
86
+
87
+ return [...nodeVoltages.entries()]
88
+ .filter(([node]) => node !== '0')
89
+ .map(([node, value]) => ({
90
+ name: `v(${node})`,
91
+ value: SpiceFallbackSimulationEngine.#round(value)
92
+ }))
93
+ }
94
+
95
+ /**
96
+ * Builds constant current result rows from independent DC current sources.
97
+ * @param {string} spiceString SPICE netlist text.
98
+ * @returns {{ name: string, value: number }[]}
99
+ */
100
+ static #buildCurrentRows(spiceString) {
101
+ const rows = []
102
+
103
+ for (const line of SpiceFallbackSimulationEngine.#componentLines(
104
+ spiceString
105
+ )) {
106
+ const tokens = line.split(/\s+/).filter(Boolean)
107
+ const name = tokens[0] || ''
108
+ if (!/^i/i.test(name) || tokens.length < 4) continue
109
+
110
+ rows.push({
111
+ name: `i(${name})`,
112
+ value:
113
+ SpiceFallbackSimulationEngine.#parseDcValue(
114
+ tokens.slice(3)
115
+ ) ?? 0
116
+ })
117
+ }
118
+
119
+ return rows
120
+ }
121
+
122
+ /**
123
+ * Returns non-comment component lines from a netlist.
124
+ * @param {string} spiceString SPICE netlist text.
125
+ * @returns {string[]}
126
+ */
127
+ static #componentLines(spiceString) {
128
+ return String(spiceString || '')
129
+ .split(/\r?\n/)
130
+ .map((line) => line.trim())
131
+ .filter(
132
+ (line) => line && !line.startsWith('*') && !line.startsWith('.')
133
+ )
134
+ }
135
+
136
+ /**
137
+ * Parses a DC source value from component tail tokens.
138
+ * @param {string[]} tokens Component tokens after node names.
139
+ * @returns {number | undefined}
140
+ */
141
+ static #parseDcValue(tokens) {
142
+ const dcIndex = tokens.findIndex(
143
+ (token) => token.toLowerCase() === 'dc'
144
+ )
145
+ const valueToken = dcIndex >= 0 ? tokens[dcIndex + 1] : tokens[0]
146
+
147
+ return SpiceDirectiveParser.parseNumber(valueToken)
148
+ }
149
+
150
+ /**
151
+ * Looks up a node voltage by normalized node name.
152
+ * @param {Map<string, number>} nodeVoltages Node voltage map.
153
+ * @param {string} nodeName Node name.
154
+ * @returns {number | undefined}
155
+ */
156
+ static #lookupNodeValue(nodeVoltages, nodeName) {
157
+ return nodeVoltages.get(String(nodeName || '').toLowerCase())
158
+ }
159
+
160
+ /**
161
+ * Rounds numeric output to a stable precision.
162
+ * @param {number} value Numeric value.
163
+ * @returns {number}
164
+ */
165
+ static #round(value) {
166
+ return Number(Number(value).toPrecision(12))
167
+ }
168
+ }
@@ -0,0 +1,234 @@
1
+ const PROBE_METADATA_COMMENT_PATTERNS = [
2
+ {
3
+ probeType: 'voltage',
4
+ pattern:
5
+ /^\s*\*\s*(?:ecadforge_voltage_probe|circuitjson_voltage_probe|simulation_voltage_probe)\s+(.+)\s*$/,
6
+ requiredFields: [
7
+ 'simulation_voltage_probe_id',
8
+ 'spice_vector',
9
+ 'source_node_name'
10
+ ]
11
+ },
12
+ {
13
+ probeType: 'current',
14
+ pattern:
15
+ /^\s*\*\s*(?:ecadforge_current_probe|circuitjson_current_probe|simulation_current_probe)\s+(.+)\s*$/,
16
+ requiredFields: ['simulation_current_probe_id', 'spice_vector']
17
+ }
18
+ ]
19
+
20
+ /**
21
+ * Builds diagnostics for SPICE syntax that local helpers cannot resolve.
22
+ */
23
+ export class SpiceSimulationDiagnostics {
24
+ /**
25
+ * Returns syntax diagnostics for a netlist.
26
+ * @param {string} spiceString SPICE netlist text.
27
+ * @returns {object[]}
28
+ */
29
+ static analyze(spiceString) {
30
+ const diagnostics = []
31
+ const lines = String(spiceString || '').split(/\r?\n/)
32
+
33
+ lines.forEach((line, index) => {
34
+ const lineNumber = index + 1
35
+ diagnostics.push(
36
+ ...SpiceSimulationDiagnostics.#probeMetadataDiagnostics(
37
+ line,
38
+ lineNumber
39
+ )
40
+ )
41
+
42
+ if (/^\s*\.lib\b/i.test(line)) {
43
+ diagnostics.push(
44
+ SpiceSimulationDiagnostics.#diagnostic({
45
+ code: 'spice_external_library_unsupported',
46
+ lineNumber,
47
+ message:
48
+ 'External .lib directives are not resolved by the local simulator fallback.'
49
+ })
50
+ )
51
+ }
52
+
53
+ if (/^\s*\.include\b/i.test(line)) {
54
+ diagnostics.push(
55
+ SpiceSimulationDiagnostics.#diagnostic({
56
+ code: 'spice_external_include_unsupported',
57
+ lineNumber,
58
+ message:
59
+ 'External .include directives are not resolved by the local simulator fallback.'
60
+ })
61
+ )
62
+ }
63
+
64
+ if (/\bpwl\s+repeat\b/i.test(line)) {
65
+ diagnostics.push(
66
+ SpiceSimulationDiagnostics.#diagnostic({
67
+ code: 'spice_pwl_repeat_unsupported',
68
+ lineNumber,
69
+ message:
70
+ 'PWL REPEAT source syntax is reported for callers that need a full simulator.'
71
+ })
72
+ )
73
+ }
74
+
75
+ if (/^\s*\.model\b.*\bako\s*:/i.test(line)) {
76
+ diagnostics.push(
77
+ SpiceSimulationDiagnostics.#diagnostic({
78
+ code: 'spice_pspice_ako_model_unsupported',
79
+ lineNumber,
80
+ message:
81
+ 'PSPICE AKO model aliases are not resolved by the local simulator fallback.'
82
+ })
83
+ )
84
+ }
85
+
86
+ if (/^\s*d\S*\s+\S+\s+\S+\s+\S+\s+[+-]?\d/i.test(line)) {
87
+ diagnostics.push(
88
+ SpiceSimulationDiagnostics.#diagnostic({
89
+ code: 'spice_pspice_diode_area_factor_unsupported',
90
+ lineNumber,
91
+ message:
92
+ 'PSPICE diode area factor syntax is not evaluated by the local simulator fallback.'
93
+ })
94
+ )
95
+ }
96
+
97
+ if (/\btable\s*\{/i.test(line)) {
98
+ diagnostics.push(
99
+ SpiceSimulationDiagnostics.#diagnostic({
100
+ code: 'spice_pspice_table_source_unsupported',
101
+ lineNumber,
102
+ message:
103
+ 'PSPICE TABLE source syntax is not evaluated by the local simulator fallback.'
104
+ })
105
+ )
106
+ }
107
+
108
+ if (/^\s*\.model\b.*\bvswitch\s*\(/i.test(line)) {
109
+ diagnostics.push(
110
+ SpiceSimulationDiagnostics.#diagnostic({
111
+ code: 'spice_pspice_vswitch_model_unsupported',
112
+ lineNumber,
113
+ message:
114
+ 'PSPICE VSWITCH model syntax is not evaluated by the local simulator fallback.'
115
+ })
116
+ )
117
+ }
118
+ })
119
+
120
+ return diagnostics
121
+ }
122
+
123
+ /**
124
+ * Returns diagnostics for requested plots that did not produce graphs.
125
+ * @param {{ normalizedToken: string, originalToken: string }[]} missingPlots Missing requested plot tokens.
126
+ * @returns {object[]}
127
+ */
128
+ static requestedPlotDiagnostics(missingPlots) {
129
+ return missingPlots.map((plot) => ({
130
+ severity: 'warning',
131
+ code: 'spice_requested_plot_missing',
132
+ plot: plot.originalToken,
133
+ normalizedPlot: plot.normalizedToken,
134
+ message:
135
+ 'Requested transient plot did not match simulator output: ' +
136
+ plot.originalToken
137
+ }))
138
+ }
139
+
140
+ /**
141
+ * Returns diagnostics for malformed probe metadata comments on one line.
142
+ * @param {string} line SPICE netlist line.
143
+ * @param {number} lineNumber One-based source line number.
144
+ * @returns {object[]}
145
+ */
146
+ static #probeMetadataDiagnostics(line, lineNumber) {
147
+ const diagnostics = []
148
+
149
+ for (const definition of PROBE_METADATA_COMMENT_PATTERNS) {
150
+ const match = line.match(definition.pattern)
151
+ if (!match?.[1]) continue
152
+
153
+ let parsed
154
+ try {
155
+ parsed = JSON.parse(match[1])
156
+ } catch {
157
+ diagnostics.push(
158
+ SpiceSimulationDiagnostics.#diagnostic({
159
+ code: 'spice_probe_metadata_invalid_json',
160
+ lineNumber,
161
+ probeType: definition.probeType,
162
+ message:
163
+ SpiceSimulationDiagnostics.#probeTypeLabel(
164
+ definition.probeType
165
+ ) + ' probe metadata comment contains invalid JSON.'
166
+ })
167
+ )
168
+ continue
169
+ }
170
+
171
+ if (
172
+ !SpiceSimulationDiagnostics.#hasRequiredStringFields(
173
+ parsed,
174
+ definition.requiredFields
175
+ )
176
+ ) {
177
+ diagnostics.push(
178
+ SpiceSimulationDiagnostics.#diagnostic({
179
+ code: 'spice_probe_metadata_invalid_shape',
180
+ lineNumber,
181
+ probeType: definition.probeType,
182
+ message:
183
+ SpiceSimulationDiagnostics.#probeTypeLabel(
184
+ definition.probeType
185
+ ) +
186
+ ' probe metadata comment is missing required string fields.'
187
+ })
188
+ )
189
+ }
190
+ }
191
+
192
+ return diagnostics
193
+ }
194
+
195
+ /**
196
+ * Returns true when parsed metadata contains all required string fields.
197
+ * @param {unknown} parsed Parsed metadata value.
198
+ * @param {string[]} requiredFields Required field names.
199
+ * @returns {boolean}
200
+ */
201
+ static #hasRequiredStringFields(parsed, requiredFields) {
202
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
203
+ return false
204
+ }
205
+
206
+ return requiredFields.every(
207
+ (field) => typeof parsed[field] === 'string'
208
+ )
209
+ }
210
+
211
+ /**
212
+ * Returns a display label for a probe type.
213
+ * @param {string} probeType Probe metadata type.
214
+ * @returns {string}
215
+ */
216
+ static #probeTypeLabel(probeType) {
217
+ return probeType === 'voltage' ? 'Voltage' : 'Current'
218
+ }
219
+
220
+ /**
221
+ * Builds a warning diagnostic object.
222
+ * @param {{ code: string, lineNumber: number, message: string, probeType?: string }} options Diagnostic options.
223
+ * @returns {object}
224
+ */
225
+ static #diagnostic(options) {
226
+ return {
227
+ severity: 'warning',
228
+ code: options.code,
229
+ lineNumber: options.lineNumber,
230
+ ...(options.probeType ? { probeType: options.probeType } : {}),
231
+ message: options.message
232
+ }
233
+ }
234
+ }