Coverage for klayout_pex/netlist/vacask_writer.py: 94%
320 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"""
26Writes a SPICE netlist (see spice_netlist.py) as a netlist of the VACASK simulator
27(https://codeberg.org/arpadbuermen/VACASK), to be included by a VACASK testbench.
29What differs from SPICE:
30 - names: an identifier is [A-Za-z_$][A-Za-z0-9_$]*, other names are quoted ('A,B', 'sub!'),
31 which VACASK takes with any character but whitespace (written as \\xHH, as KLayout does).
32 Two names that would get the same VACASK name (e.g. 'a b' and 'a\\x20b') are an error,
33 as VACASK would short their nets.
34 - case: VACASK is case-sensitive, the names are written as they are, the parameter names in lowercase
35 (VACASK lowercases the parameters of its devices, and so does its ng2vc, which converts PDK models)
36 - numbers: plain floats (VACASK reads the SI prefix M as mega, SPICE as milli);
37 the writer writes no parameters (.param) and expressions, though VACASK has them
38 - R, C, L: VACASK's own resistor, capacitor and inductor devices, and its builtin sources
39 - m: the $mfactor of a device, but a VACASK subcircuit takes m only if it declares it,
40 a call with m = N of another subcircuit is written as N calls;
41 an element whose model is a subcircuit that declares m (e.g. an M line of a PDK MOS) takes m too,
42 and m = 1 of another element with a model is left out (its model may be a subcircuit)
44NOTE: imports neither KLayout nor generated protobuf code, so that format-only paths can use it
45"""
47from __future__ import annotations
48from dataclasses import dataclass
49import math
50from pathlib import Path
51import re
52from typing import *
54from .expression.nodes import Expression, NumberOrExpression
55from .spice_netlist import SpiceElement, SpiceNetlist, SpiceSource, SpiceSubcircuit
58class VacaskWriterError(Exception):
59 """
60 The netlist has no VACASK equivalent, e.g. two of its nets would get the same VACASK name,
61 or the writer doesn't write it, e.g. an expression
62 """
63 pass
66ParameterValue = Union[float, str, List[float]]
69# NOTE: VACASK reads these as keywords at the start of a line (e.g. an instance named model),
70# so they are quoted wherever they are names
71RESERVED_WORDS = frozenset({
72 'include', 'section', 'endsection', 'load', 'model', 'global', 'ground', 'subckt', 'ends',
73 'parameters', 'control', 'endc', 'analysis', 'sweep', 'embed', 'save',
74})
76_IDENTIFIER = re.compile(r'[A-Za-z_$][A-Za-z0-9_$]*')
77_NODE_NUMBER = re.compile(r'0|[1-9][0-9]*') # NOTE: not 007, VACASK might read it as 7
80def vacask_identifier_value(name: str) -> str:
81 """
82 The name with each character VACASK names can't have (whitespace, unprintable) as \\xHH (of its UTF-8 bytes)
83 """
84 return ''.join(c if c.isprintable() and not c.isspace()
85 else ''.join(f"\\x{b:02x}" for b in c.encode('utf-8'))
86 for c in name)
89def vacask_identifier(name: str, is_node: bool = False) -> str:
90 """
91 The name as VACASK identifier: itself, if it is a plain identifier (or a node number),
92 else quoted (a quote doubled)
93 """
94 value = vacask_identifier_value(name)
95 if _IDENTIFIER.fullmatch(value) and value not in RESERVED_WORDS:
96 return value
97 if is_node and _NODE_NUMBER.fullmatch(value):
98 return value
99 return "'" + value.replace("'", "''") + "'"
102def vacask_number(value: float) -> str:
103 """
104 The number with 12 significant digits (as the SPICE netlist printer writes them), without SI prefix
105 """
106 return f"{value:.12g}".replace('e+', 'e')
109def vacask_value(value: ParameterValue) -> str:
110 if isinstance(value, str):
111 return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
112 if isinstance(value, (list, tuple)):
113 return '[' + ', '.join(vacask_number(v) for v in value) + ']'
114 return vacask_number(value)
117@dataclass(frozen=True, order=True)
118class VacaskDevice:
119 """
120 A device of VACASK, under the model name the netlist gives it
121 """
122 model_name: str
123 device_name: str
124 osdi_file: Optional[str] = None # None for a builtin device
127RESISTOR = VacaskDevice('resistor', 'resistor', 'resistor.osdi')
128CAPACITOR = VacaskDevice('capacitor', 'capacitor', 'capacitor.osdi')
129INDUCTOR = VacaskDevice('inductor', 'inductor', 'inductor.osdi')
130VSOURCE = VacaskDevice('vsource', 'vsource')
131ISOURCE = VacaskDevice('isource', 'isource')
132VCVS = VacaskDevice('vcvs', 'vcvs')
133VCCS = VacaskDevice('vccs', 'vccs')
134CCVS = VacaskDevice('ccvs', 'ccvs')
135CCCS = VacaskDevice('cccs', 'cccs')
136MUTUAL = VacaskDevice('mutual', 'mutual')
138ALL_DEVICES = (RESISTOR, CAPACITOR, INDUCTOR, VSOURCE, ISOURCE, VCVS, VCCS, CCVS, CCCS, MUTUAL)
140# the device and its value parameter of an element without model
141_PASSIVE_DEVICES: Dict[str, Tuple[VacaskDevice, str]] = {
142 'R': (RESISTOR, 'r'),
143 'C': (CAPACITOR, 'c'),
144 'L': (INDUCTOR, 'l'),
145}
147# the parameters (besides m) the devices of an element without model take, as they are named in SPICE
148_PASSIVE_PARAMETERS: Dict[str, Set[str]] = {
149 'R': {'noisy'},
150 'C': set(),
151 'L': set(),
152}
154_CONTROLLED_SOURCES: Dict[str, VacaskDevice] = {
155 'E': VCVS,
156 'G': VCCS,
157 'H': CCVS,
158 'F': CCCS,
159}
162class _NodeNames:
163 """
164 The VACASK names of the nodes of a subcircuit (or of the top level), which differ for different nodes
165 """
167 def __init__(self, scope: str):
168 self.scope = scope
169 self.identifier_by_name: Dict[str, str] = {}
170 self.name_by_identifier: Dict[str, str] = {}
172 def __call__(self, name: str) -> str:
173 identifier = self.identifier_by_name.get(name)
174 if identifier is None:
175 identifier = vacask_identifier(name, is_node=True)
176 other = self.name_by_identifier.setdefault(identifier, name)
177 if other != name:
178 raise VacaskWriterError(f"The nets {other!r} and {name!r} of {self.scope} "
179 f"would both be the net {identifier} in VACASK, which shorts them")
180 self.identifier_by_name[name] = identifier
181 return identifier
184class _InstanceNames:
185 """
186 The VACASK names of the instances of a subcircuit (or of the top level), which must differ
187 """
189 def __init__(self, scope: str):
190 self.scope = scope
191 self.description_by_identifier: Dict[str, str] = {}
193 def declare(self, name: str, origin: Optional[str] = None) -> str:
194 identifier = vacask_identifier(name)
195 description = repr(name) + (f" ({origin})" if origin else '')
196 other = self.description_by_identifier.get(identifier)
197 if other is not None:
198 raise VacaskWriterError(f"The instances {other} and {description} of {self.scope} "
199 f"would both be the instance {identifier} in VACASK")
200 self.description_by_identifier[identifier] = description
201 return identifier
204class VacaskWriter:
205 def __init__(self,
206 subcircuits_with_m: Collection[str] = frozenset(),
207 header: Sequence[str] = ()):
208 """
209 :param subcircuits_with_m: the subcircuits whose VACASK definition declares the multiplier m
210 (e.g. the MOS of a PDK), which a call with m = N gets as it is.
211 A call with m = N of another subcircuit is written as N calls,
212 the meaning of m in SPICE (VACASK rejects parameters a subcircuit doesn't declare).
213 :param header: the lines of the comment at the top of the netlist
214 """
215 self.subcircuits_with_m = frozenset(subcircuits_with_m)
216 self.header = list(header)
218 def write(self,
219 netlist: SpiceNetlist,
220 output_path: str | Path):
221 """
222 :raises VacaskWriterError: if the netlist has no VACASK equivalent
223 """
224 text = self.text(netlist) # NOTE: before opening the file, so that an error writes none
225 with open(output_path, 'w', encoding='utf-8') as f:
226 f.write(text)
228 def text(self, netlist: SpiceNetlist) -> str:
229 """
230 :raises VacaskWriterError: if the netlist has no VACASK equivalent
231 """
232 self._check_numbers(netlist)
233 self._check_subcircuit_names(netlist)
235 body: List[str] = []
236 used_devices: Set[VacaskDevice] = set()
238 if netlist.global_nodes:
239 body.append('global ' + ' '.join(vacask_identifier(n, is_node=True) for n in netlist.global_nodes))
240 body.append('')
242 for subcircuit in netlist.subcircuits:
243 body += self._subcircuit_lines(subcircuit, used_devices)
244 body.append('')
246 if netlist.elements:
247 scope = 'the top level'
248 instance_lines, devices = self._instance_lines(netlist.elements, scope, _NodeNames(scope))
249 used_devices |= devices
250 body += [f"model {d.model_name} {d.device_name}" for d in sorted(devices)]
251 body += instance_lines
252 body.append('')
254 lines = [f"// {line}".rstrip() for line in self.header]
255 if lines:
256 lines.append('')
257 osdi_files = sorted({d.osdi_file for d in used_devices if d.osdi_file})
258 if osdi_files:
259 lines += [f'load "{f}"' for f in osdi_files]
260 lines.append('')
261 lines += body
262 return '\n'.join(lines).rstrip('\n') + '\n'
264 # --------------------------------------------------------------------------------
266 @staticmethod
267 def _check_numbers(netlist: SpiceNetlist):
268 def check(values: Mapping[str, NumberOrExpression], owner: str):
269 for name, value in values.items():
270 if isinstance(value, Expression):
271 raise VacaskWriterError(f"The {name} of {owner} is the expression {value}, "
272 f"the VACASK writer writes numbers only")
274 def check_elements(elements: List[SpiceElement], scope: str):
275 for element in elements:
276 owner = f"the element {element.name!r} of {scope}"
277 check({'value': element.value} if element.value is not None else {}, owner)
278 check({f"parameter {n}": v for n, v in element.parameters.items()}, owner)
280 def check_no_parameters(parameters: Mapping[str, NumberOrExpression], scope: str):
281 if parameters:
282 raise VacaskWriterError(f"{scope[0].upper()}{scope[1:]} has .param lines ({', '.join(parameters)}), "
283 f"the VACASK writer writes numbers only")
285 check_no_parameters(netlist.parameters, 'the top level')
286 for subcircuit in netlist.subcircuits:
287 scope = f"subcircuit {subcircuit.name!r}"
288 check_no_parameters(subcircuit.local_parameters, scope)
289 check({f"parameter {n}": v for n, v in subcircuit.parameters.items()}, scope)
290 check_elements(subcircuit.elements, scope)
291 check_elements(netlist.elements, 'the top level')
293 @staticmethod
294 def _check_subcircuit_names(netlist: SpiceNetlist):
295 # NOTE: the models of the VACASK devices are defined in each subcircuit that uses them,
296 # where they would hide a subcircuit of the same name
297 model_names = sorted(d.model_name for d in ALL_DEVICES)
298 names: Dict[str, str] = {}
299 for subcircuit in netlist.subcircuits:
300 identifier = vacask_identifier(subcircuit.name)
301 if identifier in names:
302 raise VacaskWriterError(f"The subcircuits {names[identifier]!r} and {subcircuit.name!r} "
303 f"would both be the subcircuit {identifier} in VACASK")
304 names[identifier] = subcircuit.name
305 if subcircuit.name in model_names:
306 raise VacaskWriterError(f"The subcircuit {subcircuit.name!r} has the name of a model "
307 f"the VACASK netlist defines ({', '.join(model_names)})")
309 def _subcircuit_lines(self,
310 subcircuit: SpiceSubcircuit,
311 used_devices: Set[VacaskDevice]) -> List[str]:
312 scope = f"subcircuit {subcircuit.name!r}"
313 nodes = _NodeNames(scope)
314 ports: List[str] = []
315 for port in subcircuit.ports:
316 identifier = nodes(port)
317 if identifier in ports:
318 raise VacaskWriterError(f"The port {port!r} of {scope} is there twice")
319 ports.append(identifier)
321 lines = [f"subckt {vacask_identifier(subcircuit.name)} ({' '.join(ports)})"]
322 if subcircuit.parameters:
323 lines.append(' parameters ' + self._parameters_text(list(subcircuit.parameters.items()), scope))
325 instance_lines, devices = self._instance_lines(subcircuit.elements, scope, nodes)
326 used_devices |= devices
327 lines += [f" model {d.model_name} {d.device_name}" for d in sorted(devices)]
328 lines += [f" {line}" for line in instance_lines]
329 lines.append('ends')
330 return lines
332 def _instance_lines(self,
333 elements: List[SpiceElement],
334 scope: str,
335 nodes: _NodeNames) -> Tuple[List[str], Set[VacaskDevice]]:
336 instances = _InstanceNames(scope)
337 element_kinds = {e.name: e.kind for e in elements}
338 lines: List[str] = []
339 devices: Set[VacaskDevice] = set()
341 def referenced_instance(element: SpiceElement, name: str, kind: str) -> str:
342 if element_kinds.get(name) != kind:
343 raise VacaskWriterError(f"The element {element.name!r} of {scope} refers to {name!r}, "
344 f"which is no element {kind} there")
345 return vacask_identifier_value(name)
347 for element in elements:
348 kind = element.kind
349 master: str
350 prefix: List[Tuple[str, ParameterValue]] = [] # e.g. the value, before the element's parameters
351 parameters: List[Tuple[str, ParameterValue]] = []
352 m: Optional[float] = None
353 m_name = '$mfactor'
354 m_index = 0 # NOTE: m stays where it is among the parameters
355 copies = 1
356 element_nodes = element.nodes
358 for name, value in element.parameters.items():
359 if name.lower() == 'm':
360 m, m_index = value, len(parameters)
361 else:
362 parameters.append((name, value))
364 if kind in _PASSIVE_DEVICES and element.model is None:
365 device, value_name = _PASSIVE_DEVICES[kind]
366 devices.add(device)
367 master = device.model_name
368 if element.value is None:
369 raise VacaskWriterError(f"The element {element.name!r} of {scope} has no value")
370 unknown = [n for n, _ in parameters if n.lower() not in _PASSIVE_PARAMETERS[kind]]
371 if unknown:
372 raise VacaskWriterError(f"The element {element.name!r} of {scope} has parameters "
373 f"VACASK's {device.device_name} doesn't take: {', '.join(unknown)}")
374 prefix = [(value_name, element.value)]
375 elif kind == 'X':
376 if element.model is None:
377 raise VacaskWriterError(f"The subcircuit call {element.name!r} of {scope} has no subcircuit")
378 master = vacask_identifier(element.model)
379 if element.model in self.subcircuits_with_m:
380 m_name = 'm'
381 elif m is not None:
382 if m == 1:
383 m = None
384 elif float(m).is_integer() and m > 1:
385 copies = int(m)
386 m = None
387 else:
388 raise VacaskWriterError(
389 f"The subcircuit call {element.name!r} of {scope} has m={m:g}, "
390 f"which VACASK's {element.model} doesn't declare and which is no number of copies"
391 )
392 elif kind == 'K':
393 devices.add(MUTUAL)
394 master = MUTUAL.model_name
395 element_nodes = []
396 if len(element.references) != 2 or element.value is None:
397 raise VacaskWriterError(f"The coupling {element.name!r} of {scope} needs two inductors "
398 f"and a coupling factor")
399 prefix = [('k', element.value),
400 ('ind1', referenced_instance(element, element.references[0], 'L')),
401 ('ind2', referenced_instance(element, element.references[1], 'L'))]
402 elif kind in ('V', 'I'):
403 device = VSOURCE if kind == 'V' else ISOURCE
404 devices.add(device)
405 master = device.model_name
406 prefix = self._source_parameters(element, scope)
407 elif kind in _CONTROLLED_SOURCES:
408 device = _CONTROLLED_SOURCES[kind]
409 devices.add(device)
410 master = device.model_name
411 if element.value is None:
412 raise VacaskWriterError(f"The element {element.name!r} of {scope} has no gain")
413 prefix = [('gain', element.value)]
414 if kind in ('F', 'H'):
415 if len(element.references) != 1:
416 raise VacaskWriterError(f"The element {element.name!r} of {scope} needs "
417 f"the voltage source of its controlling current")
418 prefix.append(('ctlinst', referenced_instance(element, element.references[0], 'V')))
419 elif element.model is not None: # e.g. a diode, a MOS, a resistor of a model
420 master = vacask_identifier(element.model)
421 if kind in _PASSIVE_DEVICES and element.value is not None:
422 prefix = [(_PASSIVE_DEVICES[kind][1], element.value)]
423 if element.model in self.subcircuits_with_m:
424 m_name = 'm' # NOTE: e.g. an M line of a PDK MOS, whose VACASK model is a subcircuit
425 elif m == 1:
426 m = None # NOTE: the model may be a subcircuit, on which VACASK rejects $mfactor
427 else:
428 raise VacaskWriterError(f"The element {element.name!r} of {scope} has no model")
430 if m is not None:
431 parameters.insert(m_index, (m_name, m))
432 parameters = prefix + parameters
434 node_text = ' '.join(nodes(n) for n in element_nodes)
435 parameter_text = self._parameters_text(parameters, f"the element {element.name!r} of {scope}")
436 for copy in range(1, copies + 1):
437 if copies == 1:
438 name = instances.declare(element.name)
439 else:
440 name = instances.declare(f"{element.name}_{copy}",
441 origin=f"copy {copy} of {element.name}, m={copies}")
442 line = f"{name} ({node_text}) {master}"
443 if parameter_text:
444 line += ' ' + parameter_text
445 lines.append(line)
447 return lines, devices
449 @staticmethod
450 def _parameters_text(parameters: List[Tuple[str, ParameterValue]],
451 owner: str) -> str:
452 names: Set[str] = set()
453 items: List[str] = []
454 for name, value in parameters:
455 name = name.lower()
456 if name in names:
457 raise VacaskWriterError(f"The parameter {name} of {owner} is there twice")
458 names.add(name)
459 items.append(f"{vacask_identifier(name)}={vacask_value(value)}")
460 return ' '.join(items)
462 @staticmethod
463 def _source_parameters(element: SpiceElement,
464 scope: str) -> List[Tuple[str, ParameterValue]]:
465 """
466 The parameters of VACASK's vsource or isource for the value of a SPICE source.
468 The waveform parameters SPICE defaults to the time step or stop time of the transient analysis
469 must be given, as are those whose default differs (e.g. a pulse without fall time doesn't fall in VACASK).
470 """
471 source = element.source or SpiceSource()
472 owner = f"the source {element.name!r} of {scope}"
474 def error(problem: str) -> VacaskWriterError:
475 return VacaskWriterError(f"{owner[0].upper()}{owner[1:]}: {problem}")
477 parameters: List[Tuple[str, ParameterValue]] = []
478 function = source.function
479 if function is None:
480 parameters.append(('dc', source.dc if source.dc is not None else 0.0))
481 else:
482 a = function.arguments
483 match function.name:
484 case 'pulse':
485 if len(a) not in (6, 7):
486 raise error("PULSE needs V1 V2 TD TR TF PW and optionally PER "
487 "(SPICE defaults TR, TF, PW to the time step or stop time)")
488 if a[3] <= 0 or a[4] <= 0:
489 raise error("PULSE needs TR > 0 and TF > 0 (SPICE takes the time step for 0, "
490 "VACASK doesn't fall for TF = 0)")
491 parameters += [('type', 'pulse'), ('val0', a[0]), ('val1', a[1]), ('delay', a[2]),
492 ('rise', a[3]), ('fall', a[4]), ('width', a[5])]
493 if len(a) == 7:
494 parameters.append(('period', a[6]))
495 initial_value = a[0]
496 case 'sin':
497 if not 3 <= len(a) <= 6:
498 raise error("SIN needs VO VA FREQ and optionally TD THETA PHASE "
499 "(SPICE defaults FREQ to the stop time)")
500 vo, va, freq = a[0], a[1], a[2]
501 td, theta, phase = (a[3:] + [0.0, 0.0, 0.0])[:3]
502 if td > 0 and phase != 0:
503 raise error("SIN with TD > 0 and PHASE != 0 holds VO until TD in SPICE, "
504 "but VO + VA·sin(PHASE) in VACASK")
505 parameters += [('type', 'sine'), ('sinedc', vo), ('ampl', va), ('freq', freq)]
506 if td:
507 parameters.append(('delay', td))
508 if theta:
509 parameters.append(('theta', theta))
510 if phase:
511 parameters.append(('tdphase', phase))
512 initial_value = vo + va * math.sin(math.radians(phase))
513 case 'exp':
514 if len(a) != 6:
515 raise error("EXP needs V1 V2 TD1 TAU1 TD2 TAU2 "
516 "(SPICE defaults them to the time step)")
517 if a[4] <= a[2]:
518 raise error("EXP needs TD2 > TD1")
519 parameters += [('type', 'exp'), ('val0', a[0]), ('val1', a[1]), ('delay', a[2]),
520 ('tau1', a[3]), ('td2', a[4] - a[2]), ('tau2', a[5])]
521 initial_value = a[0]
522 case 'pwl':
523 if len(a) < 4 or len(a) % 2:
524 raise error("PWL needs two or more pairs of time and value")
525 parameters += [('type', 'pwl'), ('wave', list(a))]
526 initial_value = a[1]
527 case _:
528 raise error(f"VACASK has no source function {function.name.upper()}")
529 # NOTE: SPICE takes the DC value for the operating point, VACASK the value at time 0
530 if source.dc is not None and source.dc != initial_value:
531 raise error(f"its DC value {source.dc:g} differs from its value {initial_value:g} at time 0, "
532 f"which VACASK takes for the operating point")
533 if source.ac_magnitude is not None:
534 parameters.append(('mag', source.ac_magnitude))
535 if source.ac_phase is not None:
536 parameters.append(('phase', source.ac_phase))
537 return parameters