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

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# 

24 

25""" 

26Reads a SPICE netlist (e.g. an extracted netlist, or a simple testbench) as ngspice reads it. 

27 

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) 

41 

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 

47 

48Doesn't read (an error naming the line): .model, .include, .lib, .func, .option, analyses, .control, 

49expressions in sources, the elements B, Q, J, ... 

50 

51NOTE: imports neither KLayout nor generated protobuf code, so that format-only paths can use it 

52""" 

53 

54from __future__ import annotations 

55from dataclasses import dataclass 

56from pathlib import Path 

57import re 

58from typing import * 

59 

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) 

73 

74 

75class SpiceReaderError(Exception): 

76 """ 

77 The netlist is no SPICE netlist the reader reads (e.g. it has .include) 

78 """ 

79 pass 

80 

81 

82GROUND = '0' 

83 

84_SOURCE_FUNCTIONS = ('pulse', 'sin', 'exp', 'pwl', 'sffm', 'am', 'trnoise', 'trrandom') 

85 

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{}'=]+|.)") 

88 

89 

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 

96 

97 

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 

101 

102 

103@dataclass 

104class _Line: 

105 number: int # of the first line, if continued 

106 tokens: List[str] 

107 

108 

109class _Scope: 

110 """ 

111 The names of a subcircuit (or of the top level), case-insensitive as in SPICE 

112 """ 

113 

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] = {} 

120 

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) 

126 

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) 

136 

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 

143 

144 

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) 

148 

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 

159 

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]] = [] 

167 

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 

207 

208 if subcircuit is not None: 

209 raise SpiceReaderError(f"the subcircuit {subcircuit.name} has no .ends") 

210 

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 

218 

219 # -------------------------------------------------------------------------------- 

220 

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) 

235 

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 

259 

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}") 

265 

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 

283 

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 

304 

305 def _element(self, line: _Line, scope: _Scope) -> SpiceElement: 

306 tokens = line.tokens 

307 name = tokens[0] 

308 kind = name[0].upper() 

309 

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]] 

314 

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 

323 

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)") 

393 

394 def _source(self, name: str, tokens: List[str]) -> SpiceSource: 

395 source = SpiceSource() 

396 i = 0 

397 

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 

406 

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