Coverage for klayout_pex/netlist/spice_rc_netlist.py: 99%

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

26The resistors and capacitors of a SPICE netlist as RC networks, one per scope, to be reduced and written back. 

27 

28Reading: 

29 - the lines as ngspice reads them (see spice_syntax): continuation lines, comments, the title of a deck 

30 - a scope is a .SUBCKT … .ENDS definition, or the top level outside of them 

31 - the network of a scope has its *plain* R and C elements: exactly two nodes and a number as value 

32 (e.g. 'Rext_1 A B 426.67', 'Cext_1 A B 223.458a'), resistances > 0, capacitances ≥ 0; 

33 parallel elements add up 

34 - every other line stays as it is, in its place: devices, subcircuit instances, sources, directives, 

35 comments, and R/C elements with other values (models, parameters, expressions, R ≤ 0, C < 0); 

36 the comment lines between a line and its continuation stay with it 

37 - tie elements of a reduction (e.g. 'Rtie_1 plate 0 1e+12') are ties of their node (see RCNetwork.tie), 

38 no resistors 

39 - protected nodes (never eliminated): the .SUBCKT ports, the ground nodes '0' and 'gnd', 

40 the .GLOBAL nodes, and every node named on another line of the scope (device terminals, sources, 

41 control statements); the caller may protect more 

42 

43Writing: 

44 - the RC elements of a scope replace its plain R and C elements, at the place of the first of them: 

45 the capacitors first, then the resistors, each sorted by node names, the nodes of each element sorted 

46 - named Cred_<n> and Rred_<n>, skipping the names of the other elements of the scope, 

47 followed by the ties of the network, named Rtie_<n>, to ground '0' 

48 - the values are written in the precision KPEX writes them (see NetlistPrinter) 

49 - the lines end as the first line of the text read (e.g. CRLF) 

50""" 

51 

52from __future__ import annotations 

53 

54from dataclasses import dataclass, field 

55import itertools 

56import math 

57import re 

58from typing import * 

59 

60from ..util.unit_formatter import format_spice_number, format_spice_resistance, parse_spice_number 

61from .rc_network import RCNetwork 

62from .rc_reduction import GROUND_NODE_NAME, TIE_RESISTANCE 

63from .spice_syntax import GROUND_NODE_NAMES, SpiceSyntaxError, spice_lines 

64 

65REDUCED_CAPACITOR_PREFIX = 'Cred_' 

66REDUCED_RESISTOR_PREFIX = 'Rred_' 

67TIE_PREFIX = 'Rtie_' 

68 

69 

70class NetlistReadError(Exception): 

71 """A netlist that can't be split into scopes (e.g. .ENDS without .SUBCKT), or whose lines ngspice can't read""" 

72 pass 

73 

74 

75@dataclass 

76class SpiceScope: 

77 """A .SUBCKT definition (or the top level, with name None) and the RC network of its plain R and C""" 

78 

79 name: Optional[str] 

80 ports: List[str] 

81 network: RCNetwork = field(default_factory=RCNetwork) 

82 resistors_read: int = 0 

83 """the plain R elements read (parallel ones count separately)""" 

84 capacitors_read: int = 0 

85 """the plain C elements read""" 

86 other_element_names: Set[str] = field(default_factory=set) 

87 """the case-folded names of the elements that are no network elements""" 

88 names_on_other_lines: Set[str] = field(default_factory=set) 

89 """the words of the scope's other lines, which protect the nodes of these names""" 

90 comment_lines: List[str] = field(default_factory=list) 

91 """written above the RC elements, e.g. a summary of the reduction""" 

92 

93 @property 

94 def title(self) -> str: 

95 return f".SUBCKT {self.name}" if self.name is not None else "top level" 

96 

97 

98class _ElementsPlaceholder: 

99 """Where the RC elements of a scope are written""" 

100 

101 def __init__(self, scope: SpiceScope): 

102 self.scope = scope 

103 

104 

105_NAME_SEPARATORS = re.compile(r'[\s(),=]+') 

106_LINE_BREAK = re.compile(r'\r\n|\r|\n') 

107 

108 

109class SpiceRCNetlist: 

110 """ 

111 A SPICE netlist, its plain R and C elements as one RC network per scope (see the module docs) 

112 """ 

113 

114 def __init__(self, 

115 items: List[Union[str, _ElementsPlaceholder]], 

116 scopes: List[SpiceScope], 

117 newline: str): 

118 """ 

119 :param newline: the line ending to write (e.g. '\\r\\n') 

120 """ 

121 self._items = items 

122 self.scopes = scopes 

123 self.newline = newline 

124 

125 # ----------------------------------------------------------------------------------------- reading 

126 

127 @classmethod 

128 def read(cls, path: str) -> SpiceRCNetlist: 

129 """ 

130 :raises OSError: if the file can't be read 

131 :raises NetlistReadError: if its .SUBCKT and .ENDS don't match 

132 """ 

133 # NOTE: bytes that are no UTF-8 (e.g. in comments) are written back as they were, 

134 # and so are the line endings (newline='') 

135 with open(path, encoding='utf-8', errors='surrogateescape', newline='') as f: 

136 return cls.parse(f.read(), source=path) 

137 

138 @classmethod 

139 def parse(cls, text: str, source: str = '<text>') -> SpiceRCNetlist: 

140 """ 

141 :raises NetlistReadError: if the .SUBCKT and .ENDS don't match, or ngspice can't read the lines 

142 """ 

143 try: 

144 lines = spice_lines(text) 

145 except SpiceSyntaxError as e: 

146 raise NetlistReadError(f"{source}:{e.line_number}: {e.message}") from e 

147 

148 items: List[Union[str, _ElementsPlaceholder]] = [] 

149 scopes: List[SpiceScope] = [] 

150 global_names: Set[str] = set() 

151 top_level = SpiceScope(name=None, ports=[]) 

152 stack: List[SpiceScope] = [top_level] 

153 scopes_with_elements: Set[int] = set() # by id() 

154 in_control_block = False 

155 

156 def protect_names_of(line: str): 

157 stack[-1].names_on_other_lines.update(name for name in _NAME_SEPARATORS.split(line) if name) 

158 

159 for line in lines: 

160 verbatim = line.verbatim 

161 if line.text is None: # NOTE: a comment line, a blank line or the title 

162 items.append(verbatim) 

163 continue 

164 number, text_line = line.number, line.text 

165 tokens = text_line.split() 

166 first = tokens[0] 

167 

168 keyword = first.lower() 

169 if in_control_block: 

170 items.append(verbatim) 

171 protect_names_of(text_line) 

172 if keyword == '.endc': 

173 in_control_block = False 

174 continue 

175 

176 if keyword == '.subckt': 

177 if len(tokens) < 2: 

178 raise NetlistReadError(f"{source}:{number}: .SUBCKT without name") 

179 ports = [] 

180 for token in tokens[2:]: 

181 if '=' in token or token.lower() == 'params:': 

182 break 

183 ports.append(token) 

184 stack.append(SpiceScope(name=tokens[1], ports=ports)) 

185 items.append(verbatim) 

186 continue 

187 

188 if keyword == '.ends': 

189 if len(stack) < 2: 

190 raise NetlistReadError(f"{source}:{number}: .ENDS without .SUBCKT") 

191 scopes.append(stack.pop()) 

192 items.append(verbatim) 

193 continue 

194 

195 if keyword == '.global': 

196 global_names.update(tokens[1:]) 

197 items.append(verbatim) 

198 continue 

199 

200 if keyword == '.control': 

201 in_control_block = True 

202 

203 if keyword[0] in 'rc' and len(tokens) == 4: 

204 value = cls._element_value(tokens[3], resistor=keyword[0] == 'r') 

205 if value is not None: 

206 scope = stack[-1] 

207 network = scope.network 

208 tied_name = cls._tied_node_name(tokens) 

209 if tied_name is not None: 

210 network.tie(network.node(tied_name)) 

211 elif keyword[0] == 'r': 

212 network.add(network.node(tokens[1]), network.node(tokens[2]), conductance=1.0 / value) 

213 scope.resistors_read += 1 

214 else: 

215 network.add(network.node(tokens[1]), network.node(tokens[2]), capacitance=value) 

216 scope.capacitors_read += 1 

217 if id(scope) not in scopes_with_elements: 

218 scopes_with_elements.add(id(scope)) 

219 items.append(_ElementsPlaceholder(scope)) 

220 continue 

221 

222 if not keyword.startswith('.'): 

223 stack[-1].other_element_names.add(first.casefold()) 

224 protect_names_of(text_line) 

225 items.append(verbatim) 

226 

227 if len(stack) > 1: 

228 raise NetlistReadError(f"{source}: .SUBCKT {stack[-1].name} without .ENDS") 

229 scopes.append(top_level) 

230 

231 for scope in scopes: 

232 network = scope.network 

233 for name in itertools.chain(scope.ports, GROUND_NODE_NAMES, global_names, scope.names_on_other_lines): 

234 node = network.find_node(name) 

235 if node is not None: 

236 network.protect(node) 

237 

238 # NOTE: scopes in the order of the file, the top level last 

239 line_break = _LINE_BREAK.search(text) 

240 return cls(items=items, scopes=scopes, newline=line_break.group() if line_break else '\n') 

241 

242 @staticmethod 

243 def _tied_node_name(tokens: List[str]) -> Optional[str]: 

244 """The node of a tie element (named Rtie_<n>, between a node and ground), None for other elements""" 

245 if not tokens[0].casefold().startswith(TIE_PREFIX.casefold()): 

246 return None 

247 grounds = [name.casefold() in GROUND_NODE_NAMES for name in tokens[1:3]] 

248 if grounds == [False, True]: 

249 return tokens[1] 

250 if grounds == [True, False]: 

251 return tokens[2] 

252 return None 

253 

254 @staticmethod 

255 def _element_value(text: str, resistor: bool) -> Optional[float]: 

256 """The value of a plain R (> 0) or C (≥ 0) element, None for anything else""" 

257 try: 

258 value = parse_spice_number(text) 

259 except ValueError: 

260 return None 

261 if not math.isfinite(value): 

262 return None 

263 if resistor: 

264 return value if value > 0.0 else None 

265 return value if value >= 0.0 else None 

266 

267 # ----------------------------------------------------------------------------------------- writing 

268 

269 @staticmethod 

270 def element_lines(scope: SpiceScope) -> List[str]: 

271 """The RC elements of a scope's network, as written (see the module docs)""" 

272 network = scope.network 

273 capacitors: List[Tuple[Tuple, str, str, float]] = [] 

274 resistors: List[Tuple[Tuple, str, str, float]] = [] 

275 for node1, node2, branch in network.edges(): 

276 if network.sort_key(node2) < network.sort_key(node1): 

277 node1, node2 = node2, node1 

278 key = (network.sort_key(node1), network.sort_key(node2)) 

279 if branch.capacitance != 0.0: 

280 capacitors.append((key, network.name(node1), network.name(node2), branch.capacitance)) 

281 if branch.conductance != 0.0: 

282 resistors.append((key, network.name(node1), network.name(node2), branch.conductance)) 

283 capacitors.sort() 

284 resistors.sort() 

285 

286 taken = scope.other_element_names 

287 

288 def names(prefix: str) -> Iterator[str]: 

289 number = 0 

290 while True: 

291 number += 1 

292 name = f"{prefix}{number}" 

293 if name.casefold() not in taken: 

294 yield name 

295 

296 lines = list(scope.comment_lines) 

297 for name, (_, net1, net2, capacitance) in zip(names(REDUCED_CAPACITOR_PREFIX), capacitors): 

298 lines.append(f"{name} {net1} {net2} {format_spice_number(capacitance)}") 

299 for name, (_, net1, net2, conductance) in zip(names(REDUCED_RESISTOR_PREFIX), resistors): 

300 lines.append(f"{name} {net1} {net2} {format_spice_resistance(1.0 / conductance)}") 

301 tied_nodes = sorted(network.tied_nodes(), key=network.sort_key) 

302 for name, node in zip(names(TIE_PREFIX), tied_nodes): 

303 lines.append(f"{name} {network.name(node)} {GROUND_NODE_NAME} {format_spice_resistance(TIE_RESISTANCE)}") 

304 return lines 

305 

306 def format(self) -> str: 

307 lines: List[str] = [] 

308 for item in self._items: 

309 if isinstance(item, _ElementsPlaceholder): 

310 lines.extend(self.element_lines(item.scope)) 

311 else: 

312 lines.append(item) 

313 return self.newline.join(lines) + self.newline 

314 

315 def write(self, path: str) -> str: 

316 """ 

317 :return: the path written 

318 """ 

319 with open(path, 'w', encoding='utf-8', errors='surrogateescape', newline='') as f: 

320 f.write(self.format()) 

321 return path