Source code for spicelib.editor.spice_components

#!/usr/bin/env python
# coding=utf-8
# -------------------------------------------------------------------------------
#
#  ███████╗██████╗ ██╗ ██████╗███████╗██╗     ██╗██████╗
#  ██╔════╝██╔══██╗██║██╔════╝██╔════╝██║     ██║██╔══██╗
#  ███████╗██████╔╝██║██║     █████╗  ██║     ██║██████╔╝
#  ╚════██║██╔═══╝ ██║██║     ██╔══╝  ██║     ██║██╔══██╗
#  ███████║██║     ██║╚██████╗███████╗███████╗██║██████╔╝
#  ╚══════╝╚═╝     ╚═╝ ╚═════╝╚══════╝╚══════╝╚═╝╚═════╝
#
# Name:        spice_components.py
# Purpose:     Parse and manipulate SPICE components in a netlist
#
# Author:      Nuno Brum (nuno.brum@gmail.com)
#
# License:     refer to the LICENSE file
# -------------------------------------------------------------------------------
from collections import OrderedDict
from typing import Dict
import re
import io
import logging

from .editor_errors import UnrecognizedSyntaxError
from .primitives import Component, scan_eng, format_eng, try_value
from .updates import UpdateType, UpdatePermission
from .spice_utils import END_LINE_TERM, REPLACE_REGEXS

_logger = logging.getLogger("spicelib.SpiceEditor")

VERILOG_TYPES = (
    "bit",
    "bool",
    "boolean",
    "int8_t",
    "int8",
    "char",
    "char",
    "uint8_t",
    "uint8",
    "uchar",
    "uchar",
    "byte",
    "int16_t",
    "int16",
    "uint16_t",
    "uint16",
    "int32_t",
    "int32",
    "int",
    "uint32_t",
    "uint32",
    "uint",
    "int64_t",
    "int64",
    "uint64_t",
    "uint64",
    "shortfloat",
    "float",
    "double",
)

SPICE_KEYWORDS = (
    "noiseless",
)


# Code Optimization objects, avoiding repeated compilation of regular expressions
component_replace_regexs: Dict[str, re.Pattern] = {}
for prefix, pattern in REPLACE_REGEXS.items():
    # print(f"Compiling regex for {prefix}: {pattern}")
    component_replace_regexs[prefix] = re.compile(pattern, re.IGNORECASE)


#TODO: complete the parser and integrate it in the Spice Editor parser.
# When writing back to the netlist they should be written in the same format. Also make sure to handle line
# continuations and comments correctly when parsing and writing back to the netlist.


def tokenize_params(params_str: str) -> list[str]:
    """Split by spaces and special operators (= and ,) but keep the operators as part of the tokens
    Everything insider {} or "" or '' will be considered as part of the same token, so that parameters like
    "key={value with spaces and commas}" are correctly parsed as a single token."""
    in_quotes = None  # To keep track of which quote we are in, if any
    in_func_decl = False  # Inside {}
    tokens = []
    current_token = ""

    for char in params_str:
        # Use structural pattern matching for clearer branching and guards
        match char:
            case '"' | "'":
                if in_quotes is None:
                    in_quotes = char
                elif in_quotes == char:
                    in_quotes = None
                current_token += char
            case '{':
                in_func_decl = True
                current_token += char
            case '}':
                in_func_decl = False
                current_token += char
            case c if c in (',', '=') and in_quotes is None and not in_func_decl:
                if current_token:
                    tokens.append(current_token.strip())
                    tokens.append(c)
                    current_token = ""
            case c if c.isspace() and in_quotes is None and not in_func_decl:
                if current_token:
                    tokens.append(current_token.strip())
                    current_token = ""
            case _:
                current_token += char

    if current_token:
        tokens.append(current_token.strip())
    return tokens


def undress_designator(designator: str) -> str:
    """Removes any odd characters from the designator, such as §, which is sometimes used in the netlist but not always present. This is needed to compare the designator with the reference of the component, which does not contain these odd characters."""
    if len(designator) > 2 and designator[1] == '§':
        return designator[0] + designator[2:]
    return designator.upper()

def _parse_params(params_str: str) -> dict:
    """
    Parses the parameters string and returns a dictionary with the parameters.
    The parameters, which can be in the form of "key=value", but the value
    can contain spaces and commas, such as "key=value1, value2". Also handle type qualifiers such
    as "type key=value" where the types are the verilog defined types as defined in VERILOG_TYPES.

    :param params_str: input
    :type params_str: str
    :raises ValueError: invalid format
    :return: dict with parameters
    :rtype: dict
    """
    params = OrderedDict()

    # Now we have a list of tokens, we can parse them into key-value pairs
    tokens = tokenize_params(params_str)
    if not tokens:
        return params  # empty parameters because there were no tokens

    key = None # current key being parsed, we expect the next token to be its value
    last_key = None  # last key parsed, we expect the next token to be a comma if we are parsing a list of values for the same key
    # var_type = None # if the current token is a type qualifier, we store it here and apply it to the next key we find
    is_list = False  #

    for token in tokens:
        if token == '=':
            if key is None:
                raise ValueError(f"Unexpected '=' without a key before it in parameters string: {params_str}")
            continue
        elif token == ',':
            if last_key and last_key in params:
                is_list = True
            else:
                raise ValueError(f"Unexpected ',' without a value after it in parameters string: {params_str}")
        else:
            value = try_value(token)
            if is_list:
                if last_key is None:
                    raise ValueError(f"Unexpected value '{token}' without a key in parameters string: {params_str}")
                # already assured that the
                if isinstance(params[last_key], list):
                    params[last_key].append(value)
                else:
                    params[last_key] = [params[last_key], value]
                is_list = False
            elif key is None:
                if token in SPICE_KEYWORDS:
                    # if the token is a keyword, we consider it as a key with value True
                    params[token] = True
                elif token in VERILOG_TYPES:
                    # if the token is a verilog type, we consider it as a type qualifier for the next key
                    var_type = token
                elif isinstance(value, float | int) and last_key in params:
                    # if the token can be converted to a value, we consider it as a list of values for the last key
                    if isinstance(params[last_key], str):
                        params[last_key] += f" {token}"
                    else:
                        params[last_key] = f"{params[last_key]} {token}"
                else:
                    key = token
            else:
                # if var_type:
                #    # if there is a type qualifier, we prepend it to the key
                #    params[key] = (value, var_type)
                #    var_type = None
                # else:
                params[key] = value
                last_key = key
                key = None

    return params

def _insert_section(line: str, start: int, end: int, section: str) -> str:
    """
    Inserts a section in the line at the given start and end positions.
    Makes sure the section is surrounded by spaces and the line ends with a newline
    """
    if not line:
        return ""
    if not section:  # Nothing to insert
        return line

    section = section.strip()
    # TODO why do we need a space? In the construction 'a=1' that must become 'a=2' a space should not be needed.
    if start > 0 and line[start - 1] != ' ':
        section = ' ' + section
    if end < len(line) and line[end] != ' ' and len(section) > 1:
        section = section + ' '
    line = line[:start] + section + line[end:]
    line = line.strip()
    return line

[docs] class SpiceComponent(Component): """ Represents a SPICE component in the netlist. It allows the manipulation of the parameters and the value of the component. """ # def __init__(self, *args, **kwargs): # """Initialize the SpiceComponent""" # super().__init__(*args, **kwargs) # def absolute_reference(self) -> str: # """Get the absolute reference of the component inside the netlist # # :return: absolute reference # :rtype: str # """ # if self._netlist is not None: # return self._netlist.parent_reference() + self.reference # return self.reference def reset_attributes(self): """Update attributes of a component at a specific line in the netlist :raises NotImplementedError: When the component type is not recognized :raises UnrecognizedSyntaxError: When the line doesn't match the expected REGEX. :return: The match found :rtype: re.match :meta private: """ if self._obj is None: return prefix = self._obj[0] regex = component_replace_regexs.get(prefix, None) if regex is None: error_msg = f"Component must start with one of these letters: {','.join(REPLACE_REGEXS.keys())}\n" \ f"Got {self._obj}" _logger.error(error_msg) raise NotImplementedError(error_msg) new_line = re.sub(r'[\n\r]+\s*', ' ', self._obj) # cleans up line breaks and extra spaces and tabs match = regex.match(new_line) if match is None or match.span() == (0, 0): raise UnrecognizedSyntaxError(self._obj, regex.pattern) info = match.groupdict() self._attributes.clear() for attr in info: if attr == 'designator': self._reference = undress_designator(info[attr]) elif attr == 'nodes': self._set_ports(info[attr].split()) elif attr == 'value': if info[attr] is not None: self._attributes['value'] = info[attr].strip() elif attr == 'params': if info[attr]: self.attributes['params'] = _parse_params(info[attr]) elif attr in ('number', 'formula1', 'formula2', 'formula3'): continue # these are subgroups of VALUE, ignore else: if info[attr] is not None: # Only sets attributes that are present setattr(self, attr, info[attr]) return match
[docs] def rewrite_lines(self, stream: io.StringIO) -> int: """Write the SPICE representation of the component into a stream :return: Number of characters written :rtype: int """ # Reconstruct the line from the attributes. This will not preserve the original formatting, by reparsing # again the line updating the parameters. prefix = self.reference[0] regex = component_replace_regexs.get(prefix, None) if regex is None: error_msg = f"Component must start with one of these letters: {','.join(REPLACE_REGEXS.keys())}\n" \ f"Got {self._obj}" _logger.error(error_msg) raise NotImplementedError(error_msg) match = regex.match(self._obj) if match is None: raise UnrecognizedSyntaxError(self._obj, regex.pattern) info = match.groupdict() if self._obj is not None: new_line: str = self._obj[:] # make a copy else: new_line = '' new_line = re.sub(r'[\n\r]+\s*', ' ', new_line) offset = 0 update_done = False for attr in info: start, stop = match.span(attr) if start == -1 and stop == -1 and attr not in self._attributes: continue # this attribute is not present in the line, skip it if attr == 'designator': old_ref = undress_designator(info[attr]) if self.reference != old_ref: if '§' in info[attr]: new_ref = self.reference[0] + '§' + self.reference[1:] else: new_ref = self.reference new_line = _insert_section(new_line, start + offset, stop + offset, new_ref) offset += len(new_ref) - len(old_ref) update_done = True elif attr == 'nodes': old_nodes_str = info[attr] new_nodes_str = ' ' + self.port_names(' ') if old_nodes_str != new_nodes_str: new_line = _insert_section(new_line, start + offset, stop + offset, new_nodes_str) offset += len(new_nodes_str) - len(old_nodes_str) update_done = True elif attr == 'params': old_params_str = info[attr] or "" # in case of no params, make it empty string old_params = _parse_params(old_params_str) # Now compare the old params with the new params, if they are different, we need to update the line differences = False for key in self.params: if key not in old_params: differences = True break if self.params[key] != old_params[key]: differences = True break else: for key in old_params: if key not in self.params: differences = True break if differences: new_params = [] for key, value in self.params.items(): if isinstance(value, list): value_str = ','.join(str(v) for v in value) else: value_str = str(value) new_params.append(f"{key}={value_str}") new_params_str = ' '.join(new_params) new_line = _insert_section(new_line, start + offset, stop + offset, new_params_str) offset += len(new_params_str) - len(old_params_str) update_done = True else: if hasattr(self, attr): old_attr_value = info[attr] new_attr_value = self._attributes[attr] if isinstance(new_attr_value, (float, int)): new_attr_value = format_eng(new_attr_value) if old_attr_value != new_attr_value: new_line = _insert_section(new_line, start + offset, stop + offset, new_attr_value) offset += len(new_attr_value) - len(old_attr_value) update_done = True else: pass # attribute not present, do nothing if not update_done: # nothing changed, write original line new_line = self.obj or "" else: new_line += END_LINE_TERM stream.write(new_line) return len(new_line)
[docs] def write_lines(self, stream: io.StringIO) -> int: """Get the SPICE representation of the component as a string. This creates a new line from the attributes alone :return: number of characters written :rtype: int """ if self._obj != "": # try to rewrite the line preserving formatting return self.rewrite_lines(stream) # Write a line from the stored attributes count = stream.write(self.reference) for port in self.ports: count += stream.write(f" {port}") # Write value if present if 'value' in self._attributes: count += stream.write(f" {self.value_str}") if 'model' in self._attributes: count += stream.write(f" {self.value_str}") # Write parameters line_size = count for key, value in self.params.items(): if line_size == 0: count += stream.write("+") # continuation line line_size = 1 chars = stream.write(f" {key}={value}") count += chars line_size += chars if line_size > 80: stream.write("\n") # continuation line line_size = 0 # account for the space at the beginning of the new line stream.write(END_LINE_TERM) count += len(END_LINE_TERM) return count
[docs] def set_value(self, value): """Informs the netlist that the value of the component has changed""" perm = SpiceComponent.begin_update(self) if perm == UpdatePermission.Deny: raise PermissionError("Updates are currently not allowed.") super().set_value(value) if perm == UpdatePermission.Inform: SpiceComponent.end_update(self, self.reference, value, UpdateType.UpdateComponentValue)
[docs] def set_parameters(self, **params): """Informs the netlist that the parameters of the component have changed""" for key, value in params.items(): self.set_parameter(key, value)
[docs] def set_parameter(self, key: str, value): """Informs the netlist that a parameter of the component has changed""" perm = SpiceComponent.begin_update(self) if perm == UpdatePermission.Deny: raise PermissionError("Updates are currently not allowed") old_params = self.params if value is None: update = UpdateType.DeleteComponentParameter elif key not in old_params: update = UpdateType.AddComponentParameter else: update = UpdateType.UpdateComponentParameter super().set_parameter(key, value) if perm == UpdatePermission.Inform: SpiceComponent.end_update(self, f'{self.reference}:{key}', value, update)
[docs] def begin_update(self) -> UpdatePermission: return self._netlist.begin_update() if self._netlist else UpdatePermission.Initializing
[docs] def end_update(self, reference, value, update_type: UpdateType): if self._netlist: self._netlist.end_update(reference, value, update_type)