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
« 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"""
26The resistors and capacitors of a SPICE netlist as RC networks, one per scope, to be reduced and written back.
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
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"""
52from __future__ import annotations
54from dataclasses import dataclass, field
55import itertools
56import math
57import re
58from typing import *
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
65REDUCED_CAPACITOR_PREFIX = 'Cred_'
66REDUCED_RESISTOR_PREFIX = 'Rred_'
67TIE_PREFIX = 'Rtie_'
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
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"""
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"""
93 @property
94 def title(self) -> str:
95 return f".SUBCKT {self.name}" if self.name is not None else "top level"
98class _ElementsPlaceholder:
99 """Where the RC elements of a scope are written"""
101 def __init__(self, scope: SpiceScope):
102 self.scope = scope
105_NAME_SEPARATORS = re.compile(r'[\s(),=]+')
106_LINE_BREAK = re.compile(r'\r\n|\r|\n')
109class SpiceRCNetlist:
110 """
111 A SPICE netlist, its plain R and C elements as one RC network per scope (see the module docs)
112 """
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
125 # ----------------------------------------------------------------------------------------- reading
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)
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
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
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)
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]
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
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
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
195 if keyword == '.global':
196 global_names.update(tokens[1:])
197 items.append(verbatim)
198 continue
200 if keyword == '.control':
201 in_control_block = True
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
222 if not keyword.startswith('.'):
223 stack[-1].other_element_names.add(first.casefold())
224 protect_names_of(text_line)
225 items.append(verbatim)
227 if len(stack) > 1:
228 raise NetlistReadError(f"{source}: .SUBCKT {stack[-1].name} without .ENDS")
229 scopes.append(top_level)
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)
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')
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
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
267 # ----------------------------------------------------------------------------------------- writing
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()
286 taken = scope.other_element_names
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
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
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
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