Coverage for klayout_pex/netlist/spice_reader.py: 93%
291 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
1#
2# --------------------------------------------------------------------------------
3# SPDX-FileCopyrightText: 2024-2026 Martin Jan Köhler and Harald Pretl
4# Johannes Kepler University, Institute for Integrated Circuits.
5#
6# This file is part of KPEX
7# (see https://github.com/iic-jku/klayout-pex).
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21# SPDX-License-Identifier: GPL-3.0-or-later
22# --------------------------------------------------------------------------------
23#
25"""
26Reads a SPICE netlist (e.g. an extracted netlist, or a simple testbench) as ngspice reads it.
28Reads:
29 - the elements R, C, L, K, D, M, V, I, E, F, G, H and X, the subcircuits (.subckt, .ends),
30 .global, .param and .end
31 - diodes with a third node (e.g. a substrate), told from the area of a two-terminal diode by it being no value:
32 D1 a k sub dmod is a diode with three nodes, D1 a k dmod 2 (or {...}, '...') one with an area
33 - the lines as ngspice reads them (see spice_syntax): continuation lines, also across comment lines,
34 comments, the title of a deck
35 - numbers with SI suffix: T, G, MEG, K, M (milli), MIL (25.4 µ), U, N, P, F, A,
36 letters after them are ignored (e.g. 10pF)
37 - expressions of parameters as ngspice reads them (see expression/ngspice.py), in {...} or '...',
38 or without them in a parameter (e.g. w=w*nf, as BAG writes it): the values of .param, of the parameters
39 of subcircuits and their calls and of elements, which the netlist keeps as expressions
40 (spice_hierarchy evaluates them in the subcircuit calls)
42SPICE is case-insensitive:
43 - a node is written as it is first written in its subcircuit (or in .global),
44 a node named gnd is ground (as ngspice has it), also a port named so
45 - a subcircuit call has the name of the subcircuit's definition (if in the netlist)
46 - the parameter names are lowercase
48Doesn't read (an error naming the line): .model, .include, .lib, .func, .option, analyses, .control,
49expressions in sources, the elements B, Q, J, ...
51NOTE: imports neither KLayout nor generated protobuf code, so that format-only paths can use it
52"""
54from __future__ import annotations
55from dataclasses import dataclass
56from pathlib import Path
57import re
58from typing import *
60from ..log import warning
61from ..util.unit_formatter import parse_spice_number
62from .expression.ngspice import NGSPICE_SYNTAX
63from .expression.nodes import Number, NumberOrExpression
64from .expression.parser import ExpressionParser, ExpressionSyntaxError
65from .spice_syntax import GROUND_NODE_NAMES, SpiceSyntaxError, spice_lines
66from .spice_netlist import (
67 SpiceElement,
68 SpiceNetlist,
69 SpiceSource,
70 SpiceSourceFunction,
71 SpiceSubcircuit,
72)
75class SpiceReaderError(Exception):
76 """
77 The netlist is no SPICE netlist the reader reads (e.g. it has .include)
78 """
79 pass
82GROUND = '0'
84_SOURCE_FUNCTIONS = ('pulse', 'sin', 'exp', 'pwl', 'sffm', 'am', 'trnoise', 'trrandom')
86# NOTE: a statement is split into words of these pieces, the last one catches an unbalanced { } or '
87_WORD_PIECE = re.compile(r"\s*(\{[^{}]*\}|'[^']*'|=|[^\s{}'=]+|.)")
90def _optional_number(text: str) -> Optional[float]:
91 """The SPICE number (see parse_spice_number), None if the text is no number"""
92 try:
93 return parse_spice_number(text)
94 except ValueError:
95 return None
98def _is_value(token: str) -> bool:
99 """A number, or an expression in {...} or '...' (a word without them is a name, e.g. of a node or model)"""
100 return token[:1] in ('{', "'") or _optional_number(token) is not None
103@dataclass
104class _Line:
105 number: int # of the first line, if continued
106 tokens: List[str]
109class _Scope:
110 """
111 The names of a subcircuit (or of the top level), case-insensitive as in SPICE
112 """
114 def __init__(self, description: str, global_nodes: Dict[str, str]):
115 self.description = description
116 self.global_nodes = global_nodes
117 self.node_by_key: Dict[str, str] = {}
118 self.element_line_by_key: Dict[str, int] = {}
119 self.element_by_key: Dict[str, str] = {}
121 def node(self, name: str) -> str:
122 key = name.casefold()
123 if key in GROUND_NODE_NAMES: # NOTE: ngspice takes gnd as ground
124 return GROUND
125 return self.global_nodes.get(key) or self.node_by_key.setdefault(key, name)
127 def port(self, name: str) -> str:
128 if name == GROUND:
129 # NOTE: VACASK takes a port 0 as a node of the subcircuit, not as ground
130 raise SpiceReaderError(f"the port 0 (ground) of {self.description} is not supported")
131 if name.casefold() in GROUND_NODE_NAMES:
132 # NOTE: ngspice ties the node to ground, the port connects to nothing inside the subcircuit
133 warning(f"The port {name} of {self.description} is ground inside the subcircuit, as in ngspice")
134 return name
135 return self.node(name)
137 def add_element(self, name: str, line_number: int):
138 key = name.casefold()
139 other = self.element_line_by_key.setdefault(key, line_number)
140 if other != line_number:
141 raise SpiceReaderError(f"the element {name} of {self.description} is there already (line {other})")
142 self.element_by_key[key] = name
145class SpiceReader:
146 # NOTE: a reader of another SPICE flavour (e.g. HSPICE) reads the expressions with its own syntax
147 expression_parser = ExpressionParser(NGSPICE_SYNTAX)
149 def read(self, path: str | Path) -> SpiceNetlist:
150 """
151 :raises SpiceReaderError: if the netlist has a line the reader doesn't read (the message names the line)
152 """
153 with open(path, 'r', encoding='utf-8', errors='surrogateescape') as f:
154 text = f.read()
155 try:
156 return self.parse(text)
157 except SpiceReaderError as e:
158 raise SpiceReaderError(f"{path}: {e}") from None
160 def parse(self, text: str) -> SpiceNetlist:
161 netlist = SpiceNetlist()
162 global_nodes: Dict[str, str] = {}
163 top_scope = _Scope('the top level', global_nodes)
164 scope = top_scope
165 subcircuit: Optional[SpiceSubcircuit] = None
166 subcircuit_scopes: List[Tuple[SpiceSubcircuit, _Scope]] = []
168 for line in self._logical_lines(text):
169 card = line.tokens[0].lower()
170 try:
171 if card.startswith('.'):
172 match card:
173 case '.subckt':
174 if subcircuit is not None:
175 raise SpiceReaderError(f"nested subcircuit definitions are not supported "
176 f"(in subcircuit {subcircuit.name})")
177 subcircuit, scope = self._subcircuit(line, global_nodes)
178 netlist.subcircuits.append(subcircuit)
179 subcircuit_scopes.append((subcircuit, scope))
180 case '.ends':
181 if subcircuit is None:
182 raise SpiceReaderError(".ends without .subckt")
183 subcircuit, scope = None, top_scope
184 case '.param':
185 parameters = netlist.parameters if subcircuit is None else subcircuit.local_parameters
186 for token in line.tokens[1:]:
187 name, value = self._parameter(token)
188 parameters[name] = value
189 case '.global':
190 for name in line.tokens[1:]:
191 if name.casefold() not in global_nodes:
192 global_nodes[name.casefold()] = name
193 netlist.global_nodes.append(name)
194 case '.end':
195 break
196 case _:
197 raise SpiceReaderError(f"{line.tokens[0]} is not supported")
198 else:
199 element = self._element(line, scope)
200 scope.add_element(element.name, line.number)
201 if subcircuit is None:
202 netlist.elements.append(element)
203 else:
204 subcircuit.elements.append(element)
205 except SpiceReaderError as e:
206 raise SpiceReaderError(f"line {line.number}: {e}") from None
208 if subcircuit is not None:
209 raise SpiceReaderError(f"the subcircuit {subcircuit.name} has no .ends")
211 subcircuit_by_key = {s.name.casefold(): s.name for s in netlist.subcircuits}
212 for elements, scope in [(s.elements, sc) for s, sc in subcircuit_scopes] + [(netlist.elements, top_scope)]:
213 for element in elements:
214 if element.kind == 'X':
215 element.model = subcircuit_by_key.get(element.model.casefold(), element.model)
216 element.references = [scope.element_by_key.get(r.casefold(), r) for r in element.references]
217 return netlist
219 # --------------------------------------------------------------------------------
221 @staticmethod
222 def _logical_lines(text: str) -> Iterator[_Line]:
223 try:
224 lines = spice_lines(text)
225 except SpiceSyntaxError as e:
226 raise SpiceReaderError(str(e)) from None
227 for line in lines:
228 if line.text is None: # NOTE: a comment line, a blank line or the title
229 continue
230 try:
231 tokens = SpiceReader._tokens(line.text)
232 except SpiceReaderError as e:
233 raise SpiceReaderError(f"line {line.number}: {e}") from None
234 yield _Line(number=line.number, tokens=tokens)
236 @staticmethod
237 def _tokens(text: str) -> List[str]:
238 """
239 The words of a statement: an expression in {...} or '...' is one word, also with spaces in it,
240 and so is a parameter with spaces around its = (e.g. w = {a + b})
241 """
242 tokens: List[str] = []
243 after_equals = False
244 for match in _WORD_PIECE.finditer(text):
245 piece = match.group(1)
246 if piece in ('{', '}', "'"):
247 raise SpiceReaderError(f"{piece} has no counterpart (expressions are written {{...}} or '...')")
248 if piece == '=':
249 if not tokens:
250 raise SpiceReaderError("= without parameter name")
251 tokens[-1] += piece
252 after_equals = True
253 elif tokens and (after_equals or match.start(1) == match.start(0)):
254 tokens[-1] += piece
255 after_equals = False
256 else:
257 tokens.append(piece)
258 return tokens
260 def _parameter(self, token: str) -> Tuple[str, NumberOrExpression]:
261 name, _, text = token.partition('=')
262 if not name or not text:
263 raise SpiceReaderError(f"the parameter {token} has no {'value' if name else 'name'}")
264 return name.lower(), self._value(text, f"parameter {name}")
266 def _value(self, text: str, what: str) -> NumberOrExpression:
267 """
268 A number, or an expression: in {...} or '...', or without them (e.g. w*nf)
269 """
270 if text[:1] in ('{', "'"):
271 text = text[1:-1]
272 else:
273 number = _optional_number(text)
274 if number is not None:
275 return number
276 try:
277 expression = self.expression_parser.parse(text.strip())
278 except ExpressionSyntaxError as e:
279 raise SpiceReaderError(f"the {what} is no expression ({e})") from None
280 if isinstance(expression.root, Number): # NOTE: e.g. {1u}
281 return expression.root.value
282 return expression
284 def _subcircuit(self,
285 line: _Line,
286 global_nodes: Dict[str, str]) -> Tuple[SpiceSubcircuit, _Scope]:
287 if len(line.tokens) < 2:
288 raise SpiceReaderError(".subckt without name")
289 name = line.tokens[1]
290 scope = _Scope(f"subcircuit {name}", global_nodes)
291 ports: List[str] = []
292 parameters: Dict[str, NumberOrExpression] = {}
293 for token in line.tokens[2:]:
294 if token.lower() == 'params:':
295 continue
296 if '=' in token:
297 parameter, value = self._parameter(token)
298 parameters[parameter] = value
299 elif parameters:
300 raise SpiceReaderError(f"the port {token} of subcircuit {name} follows its parameters")
301 else:
302 ports.append(scope.port(token))
303 return SpiceSubcircuit(name=name, ports=ports, parameters=parameters), scope
305 def _element(self, line: _Line, scope: _Scope) -> SpiceElement:
306 tokens = line.tokens
307 name = tokens[0]
308 kind = name[0].upper()
310 def nodes(count: int) -> List[str]:
311 if len(tokens) < 1 + count:
312 raise SpiceReaderError(f"the element {name} needs {count} nodes")
313 return [scope.node(n) for n in tokens[1:1 + count]]
315 def parameters(rest: List[str]) -> Dict[str, NumberOrExpression]:
316 result: Dict[str, NumberOrExpression] = {}
317 for token in rest:
318 if '=' not in token:
319 raise SpiceReaderError(f"the element {name} has {token} where a parameter is expected")
320 parameter, value = self._parameter(token)
321 result[parameter] = value
322 return result
324 match kind:
325 case 'R' | 'C' | 'L':
326 element = SpiceElement(name=name, nodes=nodes(2))
327 rest = tokens[3:]
328 while rest and '=' not in rest[0]:
329 if _is_value(rest[0]) and element.value is None and element.model is None:
330 element.value = self._value(rest[0], f"value of {name}")
331 elif element.model is None:
332 element.model = rest[0]
333 else:
334 raise SpiceReaderError(f"the element {name} has {rest[0]} where a parameter is expected")
335 rest = rest[1:]
336 element.parameters = parameters(rest)
337 value_parameter = kind.lower()
338 if value_parameter in element.parameters:
339 if element.value is not None:
340 raise SpiceReaderError(f"the element {name} has two values")
341 element.value = element.parameters.pop(value_parameter)
342 return element
343 case 'K':
344 if len(tokens) != 4:
345 raise SpiceReaderError(f"the coupling {name} needs two inductors and a coupling factor")
346 return SpiceElement(name=name, references=tokens[1:3],
347 value=self._value(tokens[3], f"coupling factor of {name}"))
348 case 'D' | 'M':
349 count = 2 if kind == 'D' else 4
350 # NOTE: a diode with a third node (e.g. a substrate), D1 a k sub dmod, has a name where the area
351 # of a two-terminal diode, D1 a k dmod 2, has a value
352 if kind == 'D' and len(tokens) > 4 and '=' not in tokens[4] and not _is_value(tokens[4]):
353 count = 3
354 element = SpiceElement(name=name, nodes=nodes(count))
355 if len(tokens) < 2 + count:
356 raise SpiceReaderError(f"the element {name} has no model")
357 element.model = tokens[1 + count]
358 rest = tokens[2 + count:]
359 if kind == 'D' and rest and '=' not in rest[0]: # the area
360 element.parameters['area'] = self._value(rest[0], f"area of {name}")
361 rest = rest[1:]
362 element.parameters.update(parameters(rest))
363 return element
364 case 'X':
365 rest = [t for t in tokens[1:] if t.lower() != 'params:']
366 count = next((i for i, t in enumerate(rest) if '=' in t), len(rest)) - 1
367 if count < 0:
368 raise SpiceReaderError(f"the subcircuit call {name} has no subcircuit")
369 return SpiceElement(name=name, nodes=[scope.node(n) for n in rest[:count]],
370 model=rest[count], parameters=parameters(rest[count + 1:]))
371 case 'V' | 'I':
372 element_nodes = nodes(2)
373 expression = next((t for t in tokens[3:] if t[:1] in ('{', "'")), None)
374 if expression is not None:
375 raise SpiceReaderError(f"the source {name} has the expression {expression}, "
376 f"which is not supported")
377 rest = ' '.join(tokens[3:]).replace('(', ' ').replace(')', ' ').replace(',', ' ').split()
378 return SpiceElement(name=name, nodes=element_nodes, source=self._source(name, rest))
379 case 'E' | 'G':
380 if len(tokens) != 6:
381 raise SpiceReaderError(f"the controlled source {name} needs 4 nodes and a gain "
382 f"(VALUE, POLY and TABLE are not supported)")
383 return SpiceElement(name=name, nodes=nodes(4), value=self._value(tokens[5], f"gain of {name}"))
384 case 'F' | 'H':
385 if len(tokens) != 5:
386 raise SpiceReaderError(f"the controlled source {name} needs 2 nodes, a voltage source "
387 f"and a gain (POLY is not supported)")
388 return SpiceElement(name=name, nodes=nodes(2), references=[tokens[3]],
389 value=self._value(tokens[4], f"gain of {name}"))
390 case _:
391 raise SpiceReaderError(f"the element {name} is not supported (supported are "
392 f"R, C, L, K, D, M, V, I, E, F, G, H and X)")
394 def _source(self, name: str, tokens: List[str]) -> SpiceSource:
395 source = SpiceSource()
396 i = 0
398 def numbers_from(start: int, max_count: int) -> List[float]:
399 result = []
400 while start + len(result) < len(tokens) and len(result) < max_count:
401 value = _optional_number(tokens[start + len(result)])
402 if value is None:
403 break
404 result.append(value)
405 return result
407 while i < len(tokens):
408 token = tokens[i].lower()
409 if token == 'dc':
410 values = numbers_from(i + 1, 1)
411 if not values:
412 raise SpiceReaderError(f"the source {name} has DC without value")
413 source.dc = values[0]
414 i += 2
415 elif token == 'ac':
416 values = numbers_from(i + 1, 2)
417 source.ac_magnitude = values[0] if values else 1.0
418 source.ac_phase = values[1] if len(values) > 1 else None
419 i += 1 + len(values)
420 elif token in _SOURCE_FUNCTIONS:
421 if source.function is not None:
422 raise SpiceReaderError(f"the source {name} has two functions")
423 values = numbers_from(i + 1, len(tokens))
424 source.function = SpiceSourceFunction(name=token, arguments=values)
425 i += 1 + len(values)
426 elif i == 0 and (value := _optional_number(token)) is not None:
427 source.dc = value
428 i += 1
429 else:
430 raise SpiceReaderError(f"the source {name} has {tokens[i]}, which is not supported")
431 return source