Source code for PySpice.Spice.Parser

####################################################################################################
#
# PySpice - A Spice Package for Python
# Copyright (C) 2014 Fabrice Salvaire
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
####################################################################################################

####################################################################################################

"""This module implements a partial SPICE netlist parser.

See the :command:`cir2py` tool for an example of usage of the parser.

It would be difficult to implement a full parser for Ngspice since the syntax is mainly contextual.

"""

####################################################################################################

import logging
import os

####################################################################################################

from .BasicElement import SubCircuitElement
from .Element import ElementParameterMetaClass
from .ElementParameter import FlagParameter
from .Netlist import Circuit, SubCircuit, Node

####################################################################################################

_module_logger = logging.getLogger(__name__)

####################################################################################################

[docs]class ParseError(NameError): pass
####################################################################################################
[docs]class PrefixData: """This class represents a device prefix.""" ############################################## def __init__(self, prefix, classes): self.prefix = prefix self.classes = classes number_of_positionals_min = 1000 number_of_positionals_max = 0 has_optionals = False for element_class in classes: number_of_positionals = element_class.number_of_positional_parameters number_of_positionals_min = min(number_of_positionals_min, number_of_positionals) number_of_positionals_max = max(number_of_positionals_max, number_of_positionals) has_optionals = max(has_optionals, bool(element_class.optional_parameters)) self.number_of_positionals_min = number_of_positionals_min self.number_of_positionals_max = number_of_positionals_max self.has_optionals = has_optionals self.multi_devices = len(classes) > 1 self.has_variable_number_of_pins = prefix in ('Q', 'X') # NPinElement, Q has 3 to 4 pins if self.has_variable_number_of_pins: self.number_of_pins = None else: # Q and X are single self.number_of_pins = classes[0].number_of_pins self.has_flag = False for element_class in classes: for parameter in element_class.optional_parameters.values(): if isinstance(parameter, FlagParameter): self.has_flag = True ############################################## def __len__(self): return len(self.classes) ############################################## def __iter__(self): return iter(self.classes) ############################################## @property def single(self): if not self.multi_devices: return self.classes[0] else: raise NameError()
#################################################################################################### _prefix_cache = {} for prefix, classes in ElementParameterMetaClass._classes.items(): prefix_data = PrefixData(prefix, classes) _prefix_cache[prefix] = prefix_data _prefix_cache[prefix.lower()] = prefix_data # for prefix_data in sorted(_prefix_cache.values(), key=lambda x: len(x)): # print(prefix_data.prefix, # len(prefix_data), # prefix_data.number_of_positionals_min, prefix_data.number_of_positionals_max, # prefix_data.has_optionals) # Single: # B 0 True # D 1 True # F 2 False # G 1 False # H 2 False # I 1 False # J 1 True # K 3 False # M 1 True # S 2 False # V 1 False # W 3 False # Z 1 True # Two: # E 0 1 False # L 1 2 True # Three: # C 1 2 True # R 1 2 True # NPinElement: # Q 1 1 True # X 1 1 False ####################################################################################################
[docs]class Statement: """ This class implements a statement, in fact a line in a Spice netlist. """ ############################################## def __init__(self, line, statement=None): self._line = line if statement is not None: self._line.lower_case_statement(statement) ############################################## def __repr__(self): return '{} {}'.format(self.__class__.__name__, repr(self._line)) ##############################################
[docs] def value_to_python(self, x): if x: if str(x)[0].isdigit(): return str(x) else: return "'{}'".format(x) else: return ''
##############################################
[docs] def values_to_python(self, values): return [self.value_to_python(x) for x in values]
##############################################
[docs] def kwargs_to_python(self, kwargs): return ['{}={}'.format(key, self.value_to_python(value)) for key, value in kwargs.items()]
##############################################
[docs] def join_args(self, args): return ', '.join(args)
####################################################################################################
[docs]class Comment(Statement): pass
####################################################################################################
[docs]class Title(Statement): """ This class implements a title definition. """ ############################################## def __init__(self, line): super().__init__(line, statement='title') self._title = self._line.right_of('.title') ############################################## def __str__(self): return self._title ############################################## def __repr__(self): return 'Title {}'.format(self._title)
####################################################################################################
[docs]class Lib(Statement): """ This class implements a library definition. """ ############################################## def __init__(self, line): super().__init__(line, statement='lib') self._lib = self._line.right_of('.lib') ############################################## def __str__(self): return self._lib ############################################## def __repr__(self): return 'Lib {}'.format(self._lib) ##############################################
[docs] def to_python(self, netlist_name): return '{}.lib({})'.format(netlist_name, self._lib) + os.linesep
####################################################################################################
[docs]class Include(Statement): """ This class implements a include definition. """ ############################################## def __init__(self, line): super().__init__(line, statement='include') self._include = self._line.right_of('.include').strip('"') ############################################## def __str__(self): return self._include ############################################## def __repr__(self): return 'Include {}'.format(self._include) ##############################################
[docs] def to_python(self, netlist_name): return '{}.include({})'.format(netlist_name, self._include) + os.linesep
####################################################################################################
[docs]class Model(Statement): """ This class implements a model definition. Spice syntax:: .model mname type (pname1=pval1 pname2=pval2) """ ############################################## def __init__(self, line): super().__init__(line, statement='model') text = line.right_of('.model').strip() import re mtch = re.match('\s*([^ \t]+)\s*([^ \t(]+)(.*)', text) self._name = mtch[1] self._model_type = mtch[2] params = mtch[3] params = params.strip('() ') self._parameters = Line.get_kwarg(params) ############################################## @property def name(self): """ Name of the model """ return self._name ############################################## def __repr__(self): return 'Model {} {} {}'.format(self._name, self._model_type, self._parameters) ##############################################
[docs] def to_python(self, netlist_name): args = self.values_to_python((self._name, self._model_type)) kwargs = self.kwargs_to_python(self._parameters) return '{}.model({})'.format(netlist_name, self.join_args(args + kwargs)) + os.linesep
##############################################
[docs] def build(self, circuit): circuit.model(self._name, self._model_type, **self._parameters)
####################################################################################################
[docs]class SubCircuitStatement(Statement): """ This class implements a sub-circuit definition. Spice syntax:: .SUBCKT name node1 ... param1=value1 ... """ ############################################## def __init__(self, line): super().__init__(line, statement='subckt') # Fixme parameters, dict_parameters = self._line.split_line('.subckt') self._name, self._nodes = parameters[0], parameters[1:] self._statements = [] ############################################## @property def name(self): """ Name of the sub-circuit. """ return self._name @property def nodes(self): """ Nodes of the sub-circuit. """ return self._nodes ############################################## def __repr__(self): text = 'SubCircuit {} {}'.format(self._name, self._nodes) + os.linesep text += os.linesep.join([' ' + repr(statement) for statement in self._statements]) return text ############################################## def __iter__(self): """ Return an iterator on the statements. """ return iter(self._statements) ##############################################
[docs] def append(self, statement): """ Append a statement to the statement's list. """ self._statements.append(statement)
##############################################
[docs] def to_python(self, ground=Node.SPICE_GROUND_NUMBER): subcircuit_name = 'subcircuit_' + self._name args = self.values_to_python([subcircuit_name] + self._nodes) source_code = '' source_code += '{} = SubCircuit({})'.format(subcircuit_name, self.join_args(args)) + os.linesep source_code += SpiceParser.netlist_to_python(subcircuit_name, self, ground) return source_code
##############################################
[docs] def build(self, ground=Node.SPICE_GROUND_NUMBER): subcircuit = SubCircuit(self._name, *self._nodes) SpiceParser._build_circuit(subcircuit, self._statements, ground) return subcircuit
####################################################################################################
[docs]class Element(Statement): """ This class implements an element definition. "{ expression }" are allowed in device line. """ _logger = _module_logger.getChild('Element') ############################################## def __init__(self, line): super().__init__(line) line_str = str(line) # self._logger.debug(os.linesep + line_str) # Retrieve device prefix self._prefix = line_str[0] prefix_data = _prefix_cache[self._prefix] # Retrieve device name start_location = 1 stop_location = line_str.find(' ') # Fixme: if stop_location == -1: self._name = line_str[start_location:stop_location] self._nodes = [] self._parameters = [] self._dict_parameters = {} # Read nodes if not prefix_data.has_variable_number_of_pins: number_of_pins = prefix_data.number_of_pins if number_of_pins: self._nodes, stop_location = self._line.read_words(stop_location, number_of_pins) else: # Q or X if prefix_data.prefix == 'Q': self._nodes, stop_location = self._line.read_words(stop_location, 3) # Fixme: optional node else: # X args, stop_location = self._line.split_words(stop_location, until='=') self._nodes = args[:-1] self._parameters.append(args[-1]) # model name # Read positionals number_of_positionals = prefix_data.number_of_positionals_min if number_of_positionals and stop_location is not None: # model is optional self._parameters, stop_location = self._line.read_words(stop_location, number_of_positionals) if prefix_data.multi_devices and stop_location is not None: remaining, stop_location = self._line.split_words(stop_location, until='=') self._parameters.extend(remaining) if prefix_data.prefix in ('V', 'I') and stop_location is not None: # merge remaining self._parameters[-1] += line_str[stop_location:] # Read optionals if prefix_data.has_optionals and stop_location is not None: kwargs, stop_location = self._line.split_words(stop_location) for kwarg in kwargs: try: key, value = kwarg.split('=') self._dict_parameters[key.lower()] = value except ValueError: if kwarg in ('off',) and prefix_data.has_flag: self._dict_parameters['off'] = True else: # Fixme: warning -> debug due to spam ... self._logger.debug(line_str) # raise NameError('Bad element line:', line_str) if prefix_data.multi_devices: for element_class in prefix_data: if len(self._parameters) == element_class.number_of_positional_parameters: break else: element_class = prefix_data.single self.factory = element_class # Move positionals passed as kwarg to_delete = [] for parameter in element_class.positional_parameters.values(): if parameter.key_parameter: i = parameter.position self._dict_parameters[parameter.attribute_name] = self._parameters[i] to_delete.append(i) for i in to_delete: del self._parameters[i] # self._logger.debug(os.linesep + self.__repr__()) ############################################## @property def name(self): """ Name of the element """ return self._name ############################################## def __repr__(self): return 'Element {0._prefix} {0._name} {0._nodes} {0._parameters} {0._dict_parameters}'.format(self) ##############################################
[docs] def translate_ground_node(self, ground): nodes = [] for node in self._nodes: if str(node) == str(ground): node = Node.SPICE_GROUND_NUMBER nodes.append(node) return nodes
##############################################
[docs] def to_python(self, netlist_name, ground=Node.SPICE_GROUND_NUMBER): nodes = self.translate_ground_node(ground) args = [self._name] if self._prefix != 'X': args += nodes + self._parameters else: # != Spice args += self._parameters + nodes args = self.values_to_python(args) kwargs = self.kwargs_to_python(self._dict_parameters) return '{}.{}({})'.format(netlist_name, self._prefix, self.join_args(args + kwargs)) + os.linesep
##############################################
[docs] def build(self, circuit, ground=Node.SPICE_GROUND_NUMBER): factory = getattr(circuit, self.factory.ALIAS) nodes = self.translate_ground_node(ground) if self._prefix != 'X': args = nodes + self._parameters else: # != Spice args = self._parameters + nodes kwargs = self._dict_parameters if self._logger.isEnabledFor(logging.DEBUG): message = ' '.join([str(x) for x in (self._prefix, self._name, nodes, self._parameters, self._dict_parameters)]) self._logger.debug(message) factory(self._name, *args, **kwargs)
####################################################################################################
[docs]class Line: """ This class implements a line in the netlist. """ _logger = _module_logger.getChild('Element') ############################################## def __init__(self, line, line_range, end_of_line_comment): self._end_of_line_comment = end_of_line_comment text, comment, self._is_comment = self._split_comment(line) self._text = text self._comment = comment self._line_range = line_range ############################################## def __repr__(self): return '{0._line_range}: {0._text} // {0._comment}'.format(self) ############################################## def __str__(self): return self._text ############################################## @property def comment(self): return self._comment @property def is_comment(self): return self._is_comment ############################################## def _split_comment(self, line): line = str(line) if line.startswith('*'): is_comment = True text = '' comment = line[1:].strip() else: is_comment = False # remove end of line comment location = -1 for marker in self._end_of_line_comment: _location = line.find(marker) if _location != -1: if location == -1: location = _location else: location = min(_location, location) if location != -1: text = line[:location].strip() comment = line[location:].strip() else: text = line comment = '' return text, comment, is_comment ##############################################
[docs] def append(self, line): text, comment, is_comment = self._split_comment(line) if text: if not self._text.endswith(' ') or text.startswith(' '): self._text += ' ' self._text += text if comment: self._comment += ' // ' + comment _slice = self._line_range self._line_range = slice(_slice.start, _slice.stop + 1)
##############################################
[docs] def lower_case_statement(self, statement): """Lower case the statement""" # statement without . prefix if self._text: lower_statement = statement.lower() _slice = slice(1, len(statement) + 1) _statement = self._text[_slice] if _statement.lower() == lower_statement: self._text = '.' + lower_statement + self._text[_slice.stop:]
##############################################
[docs] def right_of(self, text): return self._text[len(text):].strip()
##############################################
[docs] def read_words(self, start_location, number_of_words): """Read a fixed number of words separated by space.""" words = [] stop_location = None line_str = self._text number_of_words_read = 0 while number_of_words_read < number_of_words: # and start_location < len(line_str) stop_location = line_str.find(' ', start_location) if stop_location == -1: stop_location = None # read until end word = line_str[start_location:stop_location].strip() if word: number_of_words_read += 1 words.append(word) if stop_location is None: # we should stop if number_of_words_read != number_of_words: template = 'Bad element line, looking for word {}/{}:' + os.linesep message = (template.format(number_of_words_read, number_of_words) + line_str + os.linesep + ' '*start_location + '^') self._logger.warning(message) raise ParseError(message) else: if start_location < stop_location: start_location = stop_location else: # we have read a space start_location += 1 return words, stop_location
##############################################
[docs] def split_words(self, start_location, until=None): stop_location = None line_str = self._text if until is not None: location = line_str.find(until, start_location) if location != -1: stop_location = location location = line_str.rfind(' ', start_location, stop_location) if location != -1: stop_location = location else: raise NameError('Bad element line, missing key? ' + line_str) line_str = line_str[start_location:stop_location] words = [x for x in line_str.split(' ') if x] return words, stop_location
##############################################
[docs] @staticmethod def get_kwarg(text): dict_parameters = {} parts = [] for part in text.split(): if '=' in part and part != '=': left, right = [x for x in part.split('=')] parts.append(left) parts.append('=') if right: parts.append(right) else: parts.append(part) i = 0 i_stop = len(parts) while i < i_stop: if i + 1 < i_stop and parts[i + 1] == '=': key, value = parts[i], parts[i + 2] dict_parameters[key] = value i += 3 else: raise ParseError("Bad kwarg: {}".format(text)) return dict_parameters
##############################################
[docs] def split_line(self, keyword): """Split the line according to the following pattern:: keyword parameter1 parameter2 ... key1=value1 key2=value2 ... Return the list of parameters and the dictionary. """ # Fixme: cf. get_kwarg parameters = [] dict_parameters = {} text = self.right_of(keyword) parts = [] for part in text.split(): if '=' in part and part != '=': left, right = [x for x in part.split('=')] parts.append(left) parts.append('=') if right: parts.append(right) else: parts.append(part) i = 0 i_stop = len(parts) while i < i_stop: if i + 1 < i_stop and parts[i + 1] == '=': key, value = parts[i], parts[i + 2] dict_parameters[key] = value i += 3 else: parameters.append(parts[i]) i += 1 return parameters, dict_parameters
####################################################################################################
[docs]class SpiceParser: """ This class parse a Spice netlist file and build a syntax tree. Public Attributes: :attr:`circuit` :attr:`models` :attr:`subcircuits` :attr:`incl_libs` """ _logger = _module_logger.getChild('SpiceParser') ############################################## def __init__(self, path=None, source=None, end_of_line_comment=('$', '//', ';'), recurse=False, section=None): # Fixme: empty source self._path = path # For use by _parse() when recursing through files. if path is not None: with open(str(path), 'r') as f: raw_lines = f.readlines() elif source is not None: raw_lines = source.split(os.linesep) else: raise ValueError self._end_of_line_comment = end_of_line_comment lines = self._merge_lines(raw_lines) self._title = None self._statements = self._parse(lines=lines, recurse=recurse, section=section) self._find_sections() ############################################## def _merge_lines(self, raw_lines): """Merge broken lines and return a new list of lines. A line starting with "+" continues the preceding line. """ lines = [] current_line = None for line_index, line_string in enumerate(raw_lines): line_string = line_string.lstrip(' ') if line_string.startswith('+'): current_line.append(line_string[1:].strip('\r\n')) else: line_string = line_string.strip('\r\n') if line_string: _slice = slice(line_index, line_index +1) line = Line(line_string, _slice, self._end_of_line_comment) lines.append(line) # handle case with comment before line continuation if not line_string.startswith('*'): current_line = line return lines ############################################## def _parse(self, lines, recurse=False, section=None): """ Parse the lines and return a list of statements. """ # The first line in the input file must be the title, which is the only comment line that does # not need any special character in the first place. # # The last line must be .end if len(lines) <= 1: self._logger.warning('Empty Spice file: {self._path}'.format(**locals())) # raise NameError('Netlist is empty') # if lines[-1] != '.end': # raise NameError('".end" is expected at the end of the netlist') title_statement = '.title ' self._title = str(lines[0]) if self._title.startswith(title_statement): self._title = self._title[len(title_statement):] # SUBCKT and MODEL files often start with their commands as the # first line so they'll parse incorrectly if that line is removed. # For everything else, assume the first line is a TITLE line and # remove it. if str(lines[0]).startswith(('.model', '.subckt')): start_index = 0 else: start_index = 1 statements = [] skip_lines = [False] # True on top of stack means skip lines. sub_circuit = None scope = statements self.incl_libs = [] # Libraries found during recursive descent into includes. for line in lines[start_index:]: # print('>', repr(line)) text = str(line) lower_case_text = text.lower() # ! if skip_lines[-1]: if lower_case_text.startswith('.endl'): skip_lines.pop() elif line.is_comment: scope.append(Comment(line)) elif lower_case_text.startswith('.'): lower_case_text = lower_case_text[1:] if lower_case_text.startswith('subckt'): sub_circuit = SubCircuitStatement(line) statements.append(sub_circuit) scope = sub_circuit elif lower_case_text.startswith('ends'): sub_circuit = None scope = statements elif lower_case_text.startswith('title'): # override first line self._title = Title(line) scope.append(self._title) elif lower_case_text.startswith('end'): pass elif lower_case_text.startswith('model'): model = Model(line) scope.append(model) elif lower_case_text.startswith('include'): incl = Include(line) scope.append(incl) if recurse: from .Library import SpiceLibrary incl_path = os.path.join(str(self._path.directory_part()), str(incl)) self.incl_libs.append(SpiceLibrary(root_path=incl_path, recurse=recurse)) elif lower_case_text.startswith('lib'): lib = Lib(line) if section and str(lib) != section.lower(): # If the .lib statement is only followed by the name of a section, # then skip any lines in a library section whose name does not match # the library section argument. skip_lines.append(True) else: scope.append(lib) else: # options param ... # .global # .lib filename libname # .param # .func .csparam .temp .if # { expr } are allowed in .model lines and in device lines. # self._logger.warning('Parser ignored: {}'.format(line)) pass else: try: element = Element(line) scope.append(element) except ParseError: self._logger.warning('Parse error on:\n{}'.format(line)) return statements ############################################## def _find_sections(self): """ Look for model, sub-circuit and circuit definitions in the statement list. """ self.circuit = None self.subcircuits = [] self.models = [] for statement in self._statements: if isinstance(statement, Title): if self.circuit is None: self.circuit = statement else: raise NameError('More than one title') elif isinstance(statement, SubCircuitStatement): self.subcircuits.append(statement) elif isinstance(statement, Model): self.models.append(statement) ##############################################
[docs] def is_only_subcircuit(self): return bool(not self.circuit and self.subcircuits)
##############################################
[docs] def is_only_model(self): return bool(not self.circuit and not self.subcircuits and self.models)
############################################## @staticmethod def _build_circuit(circuit, statements, ground): for statement in statements: if isinstance(statement, Include): circuit.include(str(statement)) for statement in statements: if isinstance(statement, Element): statement.build(circuit, ground) elif isinstance(statement, Model): statement.build(circuit) elif isinstance(statement, SubCircuit): subcircuit = statement.build(ground) # Fixme: ok ??? circuit.subcircuit(subcircuit) ##############################################
[docs] def build_circuit(self, ground=Node.SPICE_GROUND_NUMBER): """Build a :class:`Circuit` instance. Use the *ground* parameter to specify the node which must be translated to 0 (SPICE ground node). """ circuit = Circuit(str(self._title)) self._build_circuit(circuit, self._statements, ground) return circuit
##############################################
[docs] @staticmethod def netlist_to_python(netlist_name, statements, ground=Node.SPICE_GROUND_NUMBER): source_code = '' for statement in statements: if isinstance(statement, Element): source_code += statement.to_python(netlist_name, ground) elif isinstance(statement, Include): pass elif isinstance(statement, Model): source_code += statement.to_python(netlist_name) elif isinstance(statement, SubCircuitStatement): source_code += statement.to_python(netlist_name) elif isinstance(statement, Include): source_code += statement.to_python(netlist_name) return source_code
##############################################
[docs] def to_python_code(self, ground=Node.SPICE_GROUND_NUMBER): ground = str(ground) source_code = '' if self.circuit: source_code += "circuit = Circuit('{}')".format(self._title) + os.linesep source_code += self.netlist_to_python('circuit', self._statements, ground) return source_code