Source code for spicelib.editor.spice_editor

#!/usr/bin/env python
# -------------------------------------------------------------------------------
#
#  ███████╗██████╗ ██╗ ██████╗███████╗██╗     ██╗██████╗
#  ██╔════╝██╔══██╗██║██╔════╝██╔════╝██║     ██║██╔══██╗
#  ███████╗██████╔╝██║██║     █████╗  ██║     ██║██████╔╝
#  ╚════██║██╔═══╝ ██║██║     ██╔══╝  ██║     ██║██╔══██╗
#  ███████║██║     ██║╚██████╗███████╗███████╗██║██████╔╝
#  ╚══════╝╚═╝     ╚═╝ ╚═════╝╚══════╝╚══════╝╚═╝╚═════╝
#
# Name:        spice_editor.py
# Purpose:     Class made to update Generic Spice netlists
#
# Author:      Nuno Brum (nuno.brum@gmail.com)
#
# Licence:     refer to the LICENSE file
# -------------------------------------------------------------------------------

from __future__ import annotations

import logging
import os
import re
from collections import OrderedDict
from collections.abc import Callable
from pathlib import Path
from typing import Any

from .base_editor import BaseEditor, format_eng, ComponentNotFoundError, ParameterNotFoundError, PARAM_REGEX, \
    UNIQUE_SIMULATION_DOT_INSTRUCTIONS, Component, SUBCKT_DIVIDER, HierarchicalComponent, ValueType
from .updates import UpdateType


from ..utils.detect_encoding import detect_encoding, EncodingDetectError
from ..utils.file_search import search_file_in_containers
from ..log.logfile_data import try_convert_value
from ..simulators.ltspice_simulator import LTspice
import io

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

__author__ = "Nuno Canto Brum <nuno.brum@gmail.com>"
__copyright__ = "Copyright 2021, Fribourg Switzerland"

END_LINE_TERM = '\n'  #: This controls the end of line terminator used

# A Spice netlist can only have one of the instructions below, otherwise an error will be raised

# All the regular expressions here may or may not include leading or trailing spaces
# This means that when you re-assemble parts, you need to be careful to preserve spaces when needed.
# See _insert_section()

# Regular expressions for the different components
# FLOAT_RGX = r"[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?"

# Regular expression for a number with decimal qualifier and unit
# NUMBER_RGX = FLOAT_RGX + r"(Meg|[kmuµnpfgt])?[a-zA-Z]*"


def PREFIX_AND_NODES_RGX(prefix: str, nodes_min: int, nodes_max: int = None, in_quotes: bool = False) -> str:
    """Create regex for the designator and nodes. Will not consume a trailing space.

    :param prefix: the prefix character of the element. 1 character.
    :param nodes_min: number of nodes, or minimum number of nodes
    :param nodes_max: maximum number of nodes. None means: fixed number of nodes = nodes_min. Defaults to None
    :param in_quotes: whether the nodes may be enclosed in quotes « » (qspice). Defaults to False
    :return: regex for the designator and nodes
    """
    nodes_str = str(nodes_min)
    if nodes_max is not None:
        nodes_str += "," + str(nodes_max)
        # designator: word
        # nodes: 1 or more words with signs and . allowed. DO NOT include '=' (like with \S) as it will mess up params
        # The ¥ is for qspice
    if in_quotes:
        return "^(?P<designator>" + prefix + "§?\\w+)(?P<nodes>\\s+«(?:\\s?[\\w+-\\.¥«´»]+){" + nodes_str + "}\\s*»)"
    else:
        return "^(?P<designator>" + prefix + "§?\\w+)(?P<nodes>(?:\\s+[\\w+-\\.¥«»]+){" + nodes_str + "})"


# Optional comment at end of line. Will consume trailing spaces and is to be used on all lines.
COMMENT_RGX = r"(?:\s+;.*)?\\?\s*$"

# Potential model name, probably needs expanding. Will require a leading space
MODEL_OR_VALUE_RGX = r"\s+(?P<value>[\w\.\-\{\}]+)"

# the rest of the line. Cannot be used with PARAM.
# Includes the comment regex and will expect to finish the line.
ANY_VALUE_RGX = r"\s+(?P<value>.*)" + COMMENT_RGX

# maybe a value. Will require a leading space
MAYBE_VALUE_RGX = r"\s+(?P<value>.*?)"

# no value
NO_VALUE_RGX = r"\s?(?P<value>)?"


def VALUE_RGX(prefix: str, number_regex_suffix: str) -> str:
    """Regex for a value, or a formula that is a single word, or is enclosed by "" or '' or {}.
    Will require a leading space, but not a trailing space.

    :param prefix: optional parameter style prefix letter for the value matching. Must be empty or 1 character.
    :param number_regex_suffix: a regex that represents any decimal qualifiers or units
    :return: the regex for a regular value
    """
    my_prefix = ""
    if len(prefix) == 1:
        my_prefix = "(" + prefix + "\\s?=\\s?)?"
    return "\\s+" + my_prefix + "(?P<value>(?P<number>[-+]?[0-9]*\\.?[0-9]+([eE][-+]?[0-9]+)?" + \
        number_regex_suffix + ")?(?P<formula1>\")?(?P<formula2>')?(?P<formula3>{)?" + \
        "(?(number)|(?(formula1).*\"|(?(formula2).*'|(?(formula3).*}|\\S*)))))"


# Parameters expression of the type: key = value.
# key must be a full word without signs or dots
# Value may be composite, and contain multiple spaces and quotes.
# Includes the comment regex and will expect to finish the line.
PARAM_RGX = r"(?P<params>(\s+\w+\s*(=\s*[\w\{\}\(\)\-\+\*\/%\.\,'\"\s]+)?)*)?" + COMMENT_RGX


REPLACE_REGEXS = {
    'A': r"",  # LTspice Only : Special Functions, Parameter substitution not supported
    # Bxxx n001 n002 [VIRP]=<expression> [ic=<value>] ...
    'B': PREFIX_AND_NODES_RGX("B", 2) + r"\s+(?P<value>[VIBR]\s*=(\s*[\w\{\}\(\)\-\+\*\/%\.\<\>\?\:\"\']+)*)" + PARAM_RGX,  # Behavioral source
    # Cxxx n1 n2 <capacitance> [ic=<value>] ...
    # Cxxx n+ n- <value> <mname> <m=val> <scale=val> <temp=val> ...
    # Cxxx n1 n2 C=<capacitance> [ic=<value>] ...
    # Cxxx n1 n2 Q=<expression> [ic=<value>] [m=<value>] ...         
    'C': PREFIX_AND_NODES_RGX("C", 2) + VALUE_RGX("C", r"[muµnpfgt]?F?\d*") + PARAM_RGX,  # Capacitor
    # Dxxx anode cathode <model> [area] [off] [m=<val>] [n=<val>] [temp=<value>] ...
    # Dxxx n+ n- mname <area=val> <m=val> <pj=val> <off> ...         
    'D': PREFIX_AND_NODES_RGX("D", 2) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Diode
    # Exxx n+ n- nc+ nc- <gain>
    # Exxx n+ n- nc+ nc- table=(<value pair>, <value pair>, ...)
    # Exxx n+ n- nc+ nc- Laplace=<func(s)>...
    # Exxx n+ n- value={<expression>}
    # Exxx n+ n- POLY(<N>) <(node1+,node1-) (node2+,node2-)+ ... (nodeN+,nodeN-)> <c0 c1 c2 c3 c4 ...>
    'E': PREFIX_AND_NODES_RGX("E", 2) + ANY_VALUE_RGX,  # Voltage Dependent Voltage Source
    # Fxxx n+ n- <Vnam> <gain>
    # Fxxx n+ n- value={<expression>}
    # Fxxx n+ n- POLY(<N>) <V1 V2 ... VN> <c0 c1 c2 c3 c4 ...>
    'F': PREFIX_AND_NODES_RGX("F", 2) + ANY_VALUE_RGX,  # Current Dependent Current Source
    # Gxxx n+ n- nc+ nc- <gain>
    # Gxxx n+ n- nc+ nc- table=(<value pair>, <value pair>, ...)
    # Gxxx n+ n- nc+ nc- Laplace=<func(s)> [window=<time>] [nfft=<number>] [mtol=<number>]
    # Gxxx n+ n- nc+ nc- value={<expression>}
    # Gxxx n+ n- POLY(<N>) <(node1+,node1-) (node2+,node2-) ... (nodeN+,nodeN-)> <c0 c1 c2 c3 c4 ...>
    'G': PREFIX_AND_NODES_RGX("G", 2) + ANY_VALUE_RGX,  # Voltage Dependent Current Source
    # Hxxx n+ n- <Vnam> <transresistance>
    # Hxxx n+ n- value={<expression>}
    # Hxxx n+ n- POLY(<N>) <V1 V2 ... VN> <c0 c1 c2 c3 c4 ...>
    'H': PREFIX_AND_NODES_RGX("H", 2) + ANY_VALUE_RGX,  # Voltage Dependent Current Source
    # Ixxx n+ n- <current> [AC=<amplitude>] [load]
    # Ixxx n+ n- PULSE(Ioff Ion Tdelay Trise Tfall Ton Tperiod Ncycles)
    # Ixxx n+ n- SINE(Ioffset Iamp Freq Td Theta Phi Ncycles)
    # Ixxx n+ n- EXP(I1 I2 Td1 Tau1 Td2 Tau2)
    # Ixxx n+ n- SFFM(Ioff Iamp Fcar MDI Fsig)
    # Ixxx n+ n- <value> step(<value1>, [<value2>], [<value3>, ...]) [load]
    # Ixxx n+ n- R=<value>
    # Ixxx n+ n- PWL(t1 i1 t2 i2 t3 i3...)
    # Ixxx n+ n- wavefile=<filename> [chan=<nnn>]
    'I': PREFIX_AND_NODES_RGX("I", 2) + MAYBE_VALUE_RGX + r"(?P<params>(\s+\w+\s*=\s*[\w\{\}\(\)\-\+\*\/%\.\,'\"\s]+)*)" + COMMENT_RGX,  # Independent Current Source
    # Jxxx D G S <model> [area] [off] [IC=Vds, Vgs] [temp=T]
    'J': PREFIX_AND_NODES_RGX("J", 3) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # JFET
    # Kxxx Lyyy Lzzz ... value
    'K': PREFIX_AND_NODES_RGX("K", 2, 99) + r"\s+(?P<value>[\+\-]?[0-9\.E+-]+[kmuµnpgt]?)" + COMMENT_RGX,  # Mutual Inductance
    # Lxxx n+ n- <value> <mname> <nt=val> <m=val> ...
    # Lxxx n+ n- L = 'expression' <tc1=value> <tc2=value>
    'L': PREFIX_AND_NODES_RGX("L", 2) + VALUE_RGX("L", r"(Meg|[kmuµnpgt])?H?\d*") + PARAM_RGX,  # Inductance
    # Mxxx Nd Ng Ns Nb <model> [m=<value>] [L=<len>] ...
    # Mxxx Nd Ng Ns <model> [L=<len>] [W=<width>]
    'M': PREFIX_AND_NODES_RGX("M", 3, 4) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # MOSFET
    # Nxxx NI1 NI2...NIX mname [<parameter>=<value>] ...
    'N': PREFIX_AND_NODES_RGX("N", 2, 99) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Verilog-A Compact Device (ngspice/openvaf)
    # Oxxx L+ L- R+ R- <model>
    'O': PREFIX_AND_NODES_RGX("O", 4) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Lossy Transmission Line
    # Pxxx NI1 NI2...NIX GND1 NO1 NO2...NOX GND2 mname <LEN=LENGTH>
    'P': PREFIX_AND_NODES_RGX("P", 2, 99) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Coupled Multiconductor Line (ngspice) or Port Device (xyce)
    # Qxxx nc nb ne <ns> <tj> mname <area=val> <areac=val> ...
    # Qxxx Collector Base Emitter [Substrate Node] model [area] [off] [IC=<Vbe, Vce>] [temp=<T>]
    'Q': PREFIX_AND_NODES_RGX("Q", 3, 5) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Bipolar
    # Rxxx n1 n2 <value> [tc=tc1, tc2, ...] [temp=<value>] ...
    # Rxxx n+ n- <value> <mname> <l=length> <w=width> ...
    # Rxxx n+ n- R = 'expression' <tc1=value> <tc2=value> <noisy=0> ...
    'R': PREFIX_AND_NODES_RGX("R", 2) + VALUE_RGX("R", r"(Meg|[kmuµnpfgt])?R?\d*") + PARAM_RGX,  # Resistor
    # Sxxx n1 n2 nc+ nc- <model> [on,off]
    'S': PREFIX_AND_NODES_RGX("S", 4) + ANY_VALUE_RGX,  # Voltage Controlled Switch
    # Txxx L+ L- R+ R- Zo=<value> Td=<value>
    'T': PREFIX_AND_NODES_RGX("T", 4) + NO_VALUE_RGX + PARAM_RGX,  # Lossless Transmission
    # (ltspice and ngspice) Uxxx N1 N2 Ncom <model> L=<len> [N=<lumps>]
    # (xyce) U<name> <type> <digital power node> <digital ground node> [node]* <model name>
    'U': PREFIX_AND_NODES_RGX("U", 3) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Uniform RC-line (ltspice and ngspice)
    # Vxxx n+ n- <voltage> [AC=<amplitude>] [Rser=<value>] [Cpar=<value>]
    # Vxxx n+ n- PULSE(V1 V2 Tdelay Trise Tfall Ton Tperiod Ncycles)
    # Vxxx n+ n- SINE(Voffset Vamp Freq Td Theta Phi Ncycles)
    # Vxxx n+ n- EXP(V1 V2 Td1 Tau1 Td2 Tau2)
    # Vxxx n+ n- SFFM(Voff Vamp Fcar MDI Fsig)
    # Vxxx n+ n- PWL(t1 v1 t2 v2 t3 v3...)
    # Vxxx n+ n- wavefile=<filename> [chan=<nnn>]
    # ex: V1 NC_08 NC_09 PWL(1u 0 +2n 1 +1m 1 +2n 0 +1m 0 +2n -1 +1m -1 +2n 0) AC 1 2 Rser=3 Cpar=4
    'V': PREFIX_AND_NODES_RGX("V", 2) + MAYBE_VALUE_RGX + r"(?P<params>(\s+\w+\s*=\s*[\w\{\}\(\)\-\+\*\/%\.\,'\"\s]+)*)" + COMMENT_RGX,  # Independent Voltage Source
    # Wxxx n1 n2 Vnam <model> [on,off]
    'W': PREFIX_AND_NODES_RGX("W", 3) + ANY_VALUE_RGX,  # Current Controlled Switch
    # Xxxx n1 n2 n3... <subckt name> [<parameter>=<expression>]
    # ex: XU1 NC_01 NC_02 NC_03 NC_04 NC_05 level2 Avol=1Meg GBW=10Meg Slew=10Meg Ilimit=25m Rail=0 Vos=0 En=0 Enk=0 In=0 Ink=0 Rin=500Meg
    #     XU1 in out1 -V +V out1 OPAx189 bla_v2 =1% bla_sp1=2 bla_sp2 = 3
    #     XU1 in out1 -V +V out1 GND OPAx189_float    
    'X': PREFIX_AND_NODES_RGX("X", 1, 99) + MODEL_OR_VALUE_RGX + r"(?:\s+(?P<params>(?:\w+\s*=\s*['\"{]?.*?['\"}]?\s*)+))?" + COMMENT_RGX,  # Subcircuit Instance
    # (ngspice) Yxxx N1 0 N2 0 mname <LEN=LENGTH>
    # (qspice) Ynnn N+ N- <frequency1> dF=<value> Ctot=<value> [Q=<value>]
    'Y': PREFIX_AND_NODES_RGX("Y", 2, 4) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Single Lossy Transmission Line
    # Zxxx D G S model [area] [m=<value>] [off] [IC=<Vds, Vgs>] [temp=<value>]
    'Z': PREFIX_AND_NODES_RGX("Z", 3) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # MESFET, IBGT
    
    # TODO
    '@': r"^(?P<designator>@§?\d+)(?P<nodes>(\s+\S+){2})\s?(?P<params>(.*)*)$",
    
    # TODO: Frequency Noise Analysis (FRA) wiggler
    # pattern = r'^@(\d+)\s+(\w+)\s+(\w+)(?:\s+delay=(\d+\w+))?(?:\s+fstart=(\d+\w+))?(?:\s+fend=(\d+\w+))?(?:\s+oct=(\d+))?(?:\s+fcoarse=(\d+\w+))?(?:\s+nmax=(\d+\w+))?\s+(\d+)\s+(\d+\w+)\s+(\d+)(?:\s+pp0=(\d+\.\d+))?(?:\s+pp1=(\d+\.\d+))?(?:\s+f0=(\d+\w+))?(?:\s+f1=(\d+\w+))?(?:\s+tavgmin=(\d+\w+))?(?:\s+tsettle=(\d+\w+))?(?:\s+acmag=(\d+))?$'
    
    # QSPICE Unique components:
    # Ãnnn VDD VSS OUT IN- IN+ MULT+ MULT- IN-- IN++ EN ¥ ¥ ¥ ¥ ¥ ¥ <TYPE> [INSTANCE PARAMETERS]
    # etc...
    'Ã': PREFIX_AND_NODES_RGX("Ã", 16) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # MultGmAmp and RRopAmp
    '¥': PREFIX_AND_NODES_RGX("¥", 16) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Various
    '€': PREFIX_AND_NODES_RGX("€", 32) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # DAC
    '£': PREFIX_AND_NODES_RGX("£", 64) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # Dual Gate Driver
    'Ø': PREFIX_AND_NODES_RGX("Ø´?", 1, 99, in_quotes=True) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # DLL
    '×': PREFIX_AND_NODES_RGX("×", 4, 100, in_quotes=True) + NO_VALUE_RGX + PARAM_RGX,  # transformer
    
    # LTSPICE Unique components:
    'Ö': PREFIX_AND_NODES_RGX("Ö", 5) + MODEL_OR_VALUE_RGX + PARAM_RGX,  # specialised OTA
}

SUBCKT_CLAUSE_FIND = r"^.SUBCKT\s+"

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

# component_replace_regexs = {prefix: re.compile(pattern, re.IGNORECASE) for prefix, pattern in REPLACE_REGEXS.items()}
subckt_regex = re.compile(r"^.SUBCKT\s+(?P<name>[\w\.]+)", re.IGNORECASE)
lib_inc_regex = re.compile(r"^\.(LIB|INC)\s+(.*)$", re.IGNORECASE)

# The following variable deprecated, and here only so that people can find it.
# It is replaced by SpiceEditor.set_custom_library_paths().
# Since I cannot keep it operational easily, I do not use the deprecated decorator or the magic from https://stackoverflow.com/a/922693.
#
# LibSearchPaths = []


def get_line_command(line) -> str:
    """
    Retrieves the type of SPICE command in the line.
    Starts by removing the leading spaces and the evaluates if it is a comment, a directive or a component.
    """
    if isinstance(line, str):
        for i in range(len(line)):
            ch = line[i]
            if ch == ' ' or ch == '\t':
                continue
            else:
                ch = ch.upper()
                if ch in REPLACE_REGEXS:  # A circuit element
                    return ch
                elif ch == '+':
                    return '+'  # This is a line continuation.
                elif ch in "#;*\n\r":  # It is a comment or a blank line
                    return "*"
                elif ch == '.':  # this is a directive
                    j = i + 1
                    while j < len(line) and (line[j] not in (' ', '\t', '\r', '\n')):
                        j += 1
                    return line[i:j].upper()
                else:
                    raise SyntaxError(f"Unrecognized command in line: \"{line}\"")
    elif isinstance(line, SpiceCircuit):
        return ".SUBCKT"
    elif isinstance(line, ControlEditor):
        return ".CONTROL"    
    
    raise SyntaxError(f'Unrecognized command in line "{line}"')


def _first_token_upped(line):
    """
    (Private function. Not to be used directly)
    Returns the first non-space character in the line. If a point '.' is found, then it gets the primitive associated.
    """
    i = 0
    while i < len(line) and line[i] in (' ', '\t'):
        i += 1
    j = i
    while i < len(line) and not (line[i] in (' ', '\t')):
        i += 1
    return line[j:i].upper()


def _is_unique_instruction(instruction):
    """
    (Private function. Not to be used directly)
    Returns true if the instruction is one of the unique instructions
    """
    cmd = get_line_command(instruction)
    return cmd in UNIQUE_SIMULATION_DOT_INSTRUCTIONS


def _clean_line(line: str) -> str:
    """remove extra spaces and clean up the line so that the regexes have an easier time matching

    :param line: spice netlist string
    :return: spice netlist string cleaned up
    """
    if line is None:
        return ""
    # Remove any leading or trailing spaces
    line = line.strip()
    # condense all space sequences to a single space
    line = re.sub(r'\s+', ' ', line).strip()
    # Remove any spaces before or after the '=' sign
    line = line.replace(" =", "=")
    line = line.replace("= ", "=")
    # Remove any spaces before or after the ',' sign (for constructions like "key=val1, val2")
    line = line.replace(" ,", ",")
    line = line.replace(", ", ",")
    return line


def _parse_params(params_str: str) -> dict:
    """
    Parses the parameters string and returns a dictionary with the parameters.
    The parameters are in the form of key=value, separated by spaces.
    The values may contain spaces or sequences with comma separation

    :param params_str: input
    :raises ValueError: invalid format
    :return: dict with parameters
    """
    params = OrderedDict()
    # make sure all spaces are condensed and there are no spaces around the = sign
    params_str = _clean_line(params_str)
    if len(params_str) == 0:
        return {}

    params = {}
    # now split in pairs
    # This will match key=value pairs, where value may contain spaces, but not unescaped '=' signs

    # TODO in case of a qspice verilog component (Ø), allow "type key=value", but that is not easy to do, as we do not know the component type here
    # Here are the allowed types, just in case this will be correctly implemented one day:
    # 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",
    # ]
    pattern = r"(\w+)=(.*?)(?<!\\)(?=\s+\w+=|$)"
    matches = re.findall(pattern, params_str)
    if matches:
        for key, value in matches:
            params[key] = try_convert_value(value)
        return params
    else:
        raise ValueError(f"Invalid parameter format: '{params_str}'")


class UnrecognizedSyntaxError(Exception):
    """Line doesn't match expected Spice syntax"""

    def __init__(self, line, regex):
        super().__init__(f'Line: "{line}" doesn\'t match regular expression "{regex}"')


class MissingExpectedClauseError(Exception):
    """Missing expected clause in Spice netlist"""


[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, parent, line_no): line = parent.netlist[line_no] super().__init__(parent, line) self.parent = parent self.update_attributes_from_line_no(line_no) def update_attributes_from_line_no(self, line_no: int) -> re.Match: """Update attributes of a component at a specific line in the netlist :param line_no: 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 :meta private: """ self.line = self.parent.netlist[line_no] prefix = self.line[0].upper() 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.line}" _logger.error(error_msg) raise NotImplementedError(error_msg) match = regex.match(self.line) if match is None: raise UnrecognizedSyntaxError(self.line, regex.pattern) info = match.groupdict() self.attributes.clear() for attr in info: if attr == 'designator': self.reference = info[attr] elif attr == 'nodes': self.ports = info[attr].split() elif attr == 'params': self.attributes['params'] = _parse_params(info[attr]) else: self.attributes[attr] = info[attr] return match def update_from_reference(self): """:meta private:""" line_no = self.parent.get_line_starting_with(self.reference) self.update_attributes_from_line_no(line_no) @property def value_str(self) -> str: # docstring inherited from Component self.update_from_reference() return self.attributes['value'] @value_str.setter def value_str(self, value: ValueType): # docstring inherited from Component if self.parent.is_read_only(): raise ValueError("Editor is read-only") self.parent.set_component_value(self.reference, value) def __getitem__(self, item): self.update_from_reference() try: return super().__getitem__(item) except KeyError: # If the attribute is not found, then it is a parameter return self.params[item] def __setitem__(self, key, value): if self.parent.is_read_only(): raise ValueError("Editor is read-only") if key == 'value': if isinstance(value, str): self.value_str = value else: self.value = value else: self.set_params(**{key: value})
[docs] class SpiceCircuit(BaseEditor): """ Represents sub-circuits within a SPICE circuit. Since sub-circuits can have sub-circuits inside them, it serves as base for the top level netlist. This hierarchical approach helps to encapsulate and protect parameters and components from edits made at a higher level. """ simulator_lib_paths: list[str] = LTspice.get_default_library_paths() """ This is initialised with typical locations found for LTspice. You can (and should, if you use wine), call `prepare_for_simulator()` once you've set the executable paths. This is a class variable, so it will be shared between all instances. :meta hide-value: """ def __init__(self, parent: SpiceCircuit = None): super().__init__() self.netlist = [] self._readonly = False self.modified_subcircuits = {} self.parent = parent
[docs] def add_update(self, name: str, value: ValueType | None, updates: UpdateType): if self.parent is not None: # check if on modified subcircuits for instance_name, subcircuit in self.parent.modified_subcircuits.items(): if subcircuit == self: return self.parent.add_update(instance_name + SUBCKT_DIVIDER + name, value, updates) # if failed will search in subcircuits # for subcircuit in self.netlist: # if subcircuit == self: # # Get the instance name # return self.parent.add_update(instance_name + SUBCKT_DIVIDER + name, value, updates) return self.parent.add_update(name, value, updates) else: return super().add_update(name, value, updates)
def get_line_starting_with(self, substr: str) -> int: """Internal function. Do not use. :meta private: """ # This function returns the line number that starts with the substr string. # If the line is not found, then -1 is returned. substr_upper = substr.upper() for line_no, line in enumerate(self.netlist): if isinstance(line, SpiceCircuit): # If it is a sub-circuit it will simply ignore it. continue elif isinstance(line, ControlEditor): # same for control editor continue line_upcase = _first_token_upped(line) if line_upcase == substr_upper: return line_no error_msg = "line starting with '%s' not found in netlist" % substr _logger.error(error_msg) raise ComponentNotFoundError(error_msg) def _add_lines(self, line_iter): """Internal function. Do not use. Add a list of lines to the netlist.""" for line in line_iter: cmd = get_line_command(line) # cmd is guaranteed to be uppercased if cmd == '.SUBCKT': sub_circuit = SpiceCircuit(self) sub_circuit.netlist.append(line) # Advance to the next non nested .ENDS finished = sub_circuit._add_lines(line_iter) if finished: self.netlist.append(sub_circuit) else: return False elif cmd == ".CONTROL": sub_circuit = ControlEditor(self) sub_circuit.content = line # Advance to the next .ENDC. There is no risk of nesting, as control sections cannot be nested. finished = sub_circuit._add_lines(line_iter) if finished: self.netlist.append(sub_circuit) else: return False elif cmd == '+': assert len(self.netlist) > 0, "ERROR: The first line cannot be starting with a +" # Concatenate the line to the previous line. Make it easy to handle: just make it 1 line. (but keep spaces etc) lastline = self.netlist[-1].rstrip('\r\n') self.netlist[-1] = lastline + line[1:] # Append to the last line, but remove the preceding newline and the leading '+' elif len(cmd) == 1 and len(line) > 1 and line[1] == '§': # strip any §, it is not always present and seems optional, so scrap it line = line[0] + line[2:] self.netlist.append(line) else: self.netlist.append(line) if cmd[:4] == '.END': # True for either .END, .ENDS and .ENDC primitives return True # If a sub-circuit is ended correctly, returns True return False # If a sub-circuit ends abruptly, returns False def _write_lines(self, f): """Internal function. Do not use.""" # This helper function writes the contents of sub-circuit to the file f for command in self.netlist: if isinstance(command, SpiceCircuit): command._write_lines(f) elif isinstance(command, ControlEditor): command._write_lines(f) else: # Writes the modified sub-circuits at the end just before the .END clause if command.upper().startswith(".ENDS"): # write here the modified sub-circuits for sub in self.modified_subcircuits.values(): sub._write_lines(f) f.write(command) def _get_param_named(self, param_name) -> tuple[int, re.Match | None]: """ Internal function. Do not use. Returns a line starting with command and matching the search with the regular expression """ search_expression = re.compile(PARAM_REGEX(r"\w+"), re.IGNORECASE) param_name_upped = param_name.upper() line_no = 0 while line_no < len(self.netlist): line = self.netlist[line_no] if isinstance(line, SpiceCircuit): # If it is a sub-circuit it will simply ignore it. line_no += 1 continue elif isinstance(line, ControlEditor): # same for control editor line_no += 1 continue cmd = get_line_command(line) if cmd == '.PARAM': matches = search_expression.finditer(line) for match in matches: if match.group("name").upper() == param_name_upped: return line_no, match line_no += 1 return -1, None # If it fails, it returns an invalid line number and No match
[docs] def get_all_parameter_names(self) -> list[str]: # docstring inherited from BaseEditor param_names = [] search_expression = re.compile(PARAM_REGEX(r"\w+"), re.IGNORECASE) for line in self.netlist: cmd = get_line_command(line) if cmd == '.PARAM': matches = search_expression.finditer(line) for match in matches: param_name = match.group('name') param_names.append(param_name.upper()) return sorted(param_names)
[docs] def get_subcircuit_names(self) -> list[str]: """ Returns a list of the names of the sub-circuits in the netlist. :return: list of sub-circuit names """ subckt_names = [] for line in self.netlist: if isinstance(line, SpiceCircuit): subckt_names.append(line.name()) return subckt_names
[docs] def get_subcircuit_named(self, name: str) -> SpiceCircuit | None: """ Returns the sub-circuit object with the given name. :param name: name of the subcircuit :return: _description_ """ for line in self.netlist: if isinstance(line, SpiceCircuit): if line.name() == name: return line if self.parent is not None: return self.parent.get_subcircuit_named(name) return None
[docs] def get_subcircuit(self, instance_name: str) -> SpiceCircuit: """ Returns an object representing a Subcircuit. This object can manipulate elements such as the SpiceEditor does. :param instance_name: Reference of the subcircuit :returns: SpiceCircuit instance :raises UnrecognizedSyntaxError: when an spice command is not recognized by spicelib :raises ComponentNotFoundError: When the reference was not found """ if SUBCKT_DIVIDER in instance_name: subckt_ref, sub_subckts = instance_name.split(SUBCKT_DIVIDER, 1) else: subckt_ref = instance_name sub_subckts = None # eliminating the code if subckt_ref in self.modified_subcircuits: # See if this was already a modified sub-circuit instance return self.modified_subcircuits[subckt_ref] line_no = self.get_line_starting_with(subckt_ref) sub_circuit_instance = self.netlist[line_no] regex = component_replace_regexs['X'] # The sub-circuit instance regex m = regex.search(sub_circuit_instance) if m: subcircuit_name = m.group('value') # last_token of the line before Params: else: raise UnrecognizedSyntaxError(sub_circuit_instance, REPLACE_REGEXS['X']) # Search for the sub-circuit in the netlist sub_circuit = self.get_subcircuit_named(subcircuit_name) if sub_circuit is not None: if sub_subckts is None: return sub_circuit else: return sub_circuit.get_subcircuit(SUBCKT_DIVIDER.join(sub_subckts)) # If we reached here is because the subcircuit was not found. Search for it in declared libraries sub_circuit = self.find_subckt_in_included_libs(subcircuit_name) if sub_circuit: if SUBCKT_DIVIDER in instance_name: return sub_circuit.get_subcircuit(sub_subckts) else: return sub_circuit else: # The search was not successful raise ComponentNotFoundError(f'Sub-circuit "{subcircuit_name}" not found')
def _get_component_line_and_regex(self, reference: str) -> tuple[int, re.Match]: """Internal function. Do not use.""" prefix = reference[0].upper() 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 {reference}" _logger.error(error_msg) raise NotImplementedError(error_msg) line_no = self.get_line_starting_with(reference) line = self.netlist[line_no] match = regex.match(line) if match is None: raise UnrecognizedSyntaxError(line, regex.pattern) return line_no, match def _insert_section(self, 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() line += END_LINE_TERM return line def _set_component_attribute(self, reference, attribute, value): """ Internal method to set the model and value of a component. """ # Using the first letter of the component to identify what is it if reference[0] == 'X' and SUBCKT_DIVIDER in reference: # Replaces a component inside of a subciruit # In this case the sub-circuit needs to be copied so that is copy is modified. A copy is created for each # instance of a sub-circuit. component_split = reference.split(SUBCKT_DIVIDER) subckt_instance = component_split[0] # reference = SUBCKT_DIVIDER.join(component_split[1:]) if subckt_instance in self.modified_subcircuits: # See if this was already a modified sub-circuit instance sub_circuit: SpiceCircuit = self.modified_subcircuits[subckt_instance] else: sub_circuit_original = self.get_subcircuit(subckt_instance) # If not will look for it. if sub_circuit_original: original_name = sub_circuit_original.name() new_name = original_name + '_' + subckt_instance # Creates a new name with the path appended sub_circuit = sub_circuit_original.clone(new_name=new_name) self.add_update(f"CLONE({original_name})", new_name, UpdateType.CloneSubcircuit) # Memorize that the copy is relative to that particular instance self.modified_subcircuits[subckt_instance] = sub_circuit # Change the call to the sub-circuit self._set_component_attribute(subckt_instance, 'model', new_name) else: raise ComponentNotFoundError(reference) # Update the component sub_circuit._set_component_attribute(SUBCKT_DIVIDER.join(component_split[1:]), attribute, value) else: line_no, match = self._get_component_line_and_regex(reference) if attribute in ('value', 'model'): # They are actually the same thing just the model is not converted. self.add_update(reference, value, UpdateType.UpdateComponentValue) if isinstance(value, (int, float)): value = format_eng(value) start = match.start('value') end = match.end('value') self.netlist[line_no] = self._insert_section(self.netlist[line_no], start, end, value) elif attribute == 'params': if not isinstance(value, dict): raise ValueError("set_component_parameters() expects to receive a dictionary") if match and match.groupdict().get('params'): params_str = match.group('params') params = _parse_params(params_str) else: params = {} for key, kvalue in value.items(): # format the kvalue if kvalue is None: kvalue_str = None elif isinstance(kvalue, str): kvalue_str = kvalue.strip() else: kvalue_str = f"{kvalue:G}" if kvalue_str is None: # remove those that must disappear if key in params: params.pop(key) update_type = UpdateType.DeleteComponentParameter else: # create or update update_type = UpdateType.UpdateComponentParameter if key in params else UpdateType.AddComponentParameter params[key] = kvalue_str update_ref = reference + SUBCKT_DIVIDER + key self.add_update(update_ref, kvalue, update_type) params_str = ' '.join([f'{key}={kvalue}' for key, kvalue in params.items()]) start = match.start('params') end = match.end('params') self.netlist[line_no] = self._insert_section(self.netlist[line_no], start, end, params_str)
[docs] def reset_netlist(self, create_blank: bool = False) -> None: """ Reverts all changes done to the netlist. If create_blank is set to True, then the netlist is blanked. :param create_blank: If True, the netlist is blanked. That is, all primitives and components are erased. :returns: None """ super().reset_netlist() self.netlist.clear()
[docs] def clone(self, **kwargs) -> SpiceCircuit: """ Creates a new copy of the SpiceCircuit. Changes done at the new copy do not affect the original. :key new_name: The new name to be given to the circuit :key type new_name: str :return: The new replica of the SpiceCircuit object """ clone = SpiceCircuit(self.parent) clone.netlist = self.netlist.copy() clone.netlist.insert(0, "***** SpiceEditor Manipulated this sub-circuit ****" + END_LINE_TERM) clone.netlist.append("***** ENDS SpiceEditor ****" + END_LINE_TERM) new_name = kwargs.get('new_name', None) if new_name is not None: clone.setname(new_name) return clone
[docs] def name(self) -> str: """ Returns the name of the Sub-Circuit. """ if len(self.netlist): for line in self.netlist: m = subckt_regex.search(line) if m: return m.group('name') else: raise RuntimeError("Unable to find .SUBCKT clause in subcircuit") else: raise RuntimeError("Empty Subcircuit")
[docs] def setname(self, new_name: str): """ Renames the sub-circuit to a new name. No check is done to the new name. It is up to the user to make sure that the new name is valid. :param new_name: The new Name. :return: Nothing """ if len(self.netlist): lines = len(self.netlist) line_no = 0 while line_no < lines: line = self.netlist[line_no] m = subckt_regex.search(line) if m: # Replacing the name in the SUBCKT Clause start = m.start('name') end = m.end('name') # print(f"Replacing '{line[start:end]}' with '{new_name}'") self.netlist[line_no] = self._insert_section(line, start, end, new_name) break line_no += 1 else: raise MissingExpectedClauseError("Unable to find .SUBCKT clause in subcircuit") # This second loop finds the .ENDS clause while line_no < lines: line = self.netlist[line_no] if get_line_command(line) == '.ENDS': self.netlist[line_no] = '.ENDS ' + new_name + END_LINE_TERM break line_no += 1 else: raise MissingExpectedClauseError("Unable to find .SUBCKT clause in subcircuit") else: # Avoiding exception by creating an empty sub-circuit self.netlist.append(f"* SpiceEditor Created this sub-circuit{END_LINE_TERM}") self.netlist.append(f'.SUBCKT {new_name}{END_LINE_TERM}') self.netlist.append(f'.ENDS {new_name}{END_LINE_TERM}')
[docs] def get_component(self, reference: str) -> SpiceComponent | SpiceCircuit: """ Returns an object representing the given reference in the schematic file. :param reference: Reference of the component :return: The SpiceComponent object or a SpiceSubcircuit in case of hierarchical design :raises: ComponentNotFoundError - In case the component is not found :raises: UnrecognizedSyntaxError when the line doesn't match the expected REGEX. :raises: NotImplementedError if there isn't an associated regular expression for the component prefix. """ if SUBCKT_DIVIDER in reference: if reference[0] != 'X': # Replaces a component inside of a subciruit raise ComponentNotFoundError("Only subcircuits can have components inside.") else: # In this case the sub-circuit needs to be copied so that is copy is modified. # A copy is created for each instance of a sub-circuit. component_split = reference.split(SUBCKT_DIVIDER) subckt_ref = component_split[0] if subckt_ref in self.modified_subcircuits: # See if this was already a modified sub-circuit instance subcircuit = self.modified_subcircuits[subckt_ref] else: subcircuit = self.get_subcircuit(subckt_ref) if len(component_split) > 1: return subcircuit.get_component(SUBCKT_DIVIDER.join(component_split[1:])) else: return subcircuit else: line_no = self.get_line_starting_with(reference) return SpiceComponent(self, line_no)
def __getitem__(self, item) -> Component | HierarchicalComponent: component = super().__getitem__(item) if component.parent != self: # encapsulate the object in HierarchicalComponent return HierarchicalComponent(component, self, item) else: return component def __delitem__(self, key): """ This method allows the user to delete a component using the syntax: del circuit['R1'] """ self.remove_component(key) def __contains__(self, key): """ This method allows the user to check if a component is in the circuit using the syntax: 'R1' in circuit """ try: self.get_component(key) return True except ComponentNotFoundError: return False def __iter__(self): """ This method allows the user to iterate over the components in the circuit using the syntax: for component in circuit: print(component) """ for line_no, line in enumerate(self.netlist): if isinstance(line, SpiceCircuit): yield from line elif isinstance(line, ControlEditor): continue # no components here, just control commands else: cmd = get_line_command(line) if cmd in REPLACE_REGEXS: yield SpiceComponent(self, line_no)
[docs] def get_component_attribute(self, reference: str, attribute: str) -> str | None: """ Returns the attribute of a component retrieved from the netlist. :param reference: Reference of the component :param attribute: Name of the attribute to be retrieved :return: Value of the attribute :raises: ComponentNotFoundError - In case the component is not found :raises: UnrecognizedSyntaxError when the line doesn't match the expected REGEX. :raises: NotImplementedError if there isn't an associated regular expression for the component prefix. """ component = self.get_component(reference) return component.attributes.get(attribute, None)
[docs] def get_component_parameters(self, reference: str) -> dict: # docstring inherited from BaseEditor line_no, match = self._get_component_line_and_regex(reference) answer = {} if match: groupdict = match.groupdict() if groupdict.get('params'): params_str = match.group('params') answer.update(_parse_params(params_str)) if groupdict.get('value'): answer['Value'] = match.group('value') return answer
[docs] def set_component_parameters(self, reference: str, **kwargs) -> None: # docstring inherited from BaseEditor if self.is_read_only(): raise ValueError("Editor is read-only") self._set_component_attribute(reference, 'params', kwargs)
[docs] def get_parameter(self, param: str) -> str: """ Returns the value of a parameter retrieved from the netlist. :param param: Name of the parameter to be retrieved :return: Value of the parameter being sought :raises: ParameterNotFoundError - In case the component is not found """ line_no, match = self._get_param_named(param) if match: return match.group('value') else: raise ParameterNotFoundError(param)
[docs] def set_parameter(self, param: str, value: ValueType) -> None: """Sets the value of a parameter in the netlist. If the parameter is not found, it is added to the netlist. Usage: :: runner.set_parameter("TEMP", 80) This adds onto the netlist the following line: :: .PARAM TEMP=80 This is an alternative to the set_parameters which is more pythonic in its usage and allows setting more than one parameter at once. :param param: Spice Parameter name to be added or updated. :param value: Parameter Value to be set. :return: Nothing """ if self.is_read_only(): raise ValueError("Editor is read-only") param_line, match = self._get_param_named(param) super().set_parameter(param, value) if isinstance(value, (int, float)): value_str = format_eng(value) else: value_str = value if match: start, stop = match.span('value') self.netlist[param_line] = self._insert_section(self.netlist[param_line], start, stop, f"{value_str}") else: # Was not found # the last two lines are typically (.backano and .end) insert_line = len(self.netlist) - 2 self.netlist.insert(insert_line, f'.PARAM {param}={value_str} ; Batch instruction' + END_LINE_TERM)
[docs] def set_component_value(self, reference: str, value: ValueType) -> None: """ Changes the value of a component, such as a Resistor, Capacitor or Inductor. For components inside sub-circuits, use the sub-circuit designator prefix with ':' as separator (Example X1:R1) Usage: :: runner.set_component_value('R1', '3.3k') runner.set_component_value('X1:C1', '10u') :param reference: Reference of the circuit element to be updated. :param value: value to be set on the given circuit element. Float and integer values will be automatically formatted as per the engineering notations 'k' for kilo, 'm', for mili and so on. :raises: ComponentNotFoundError - In case the component is not found ValueError - In case the value doesn't correspond to the expected format NotImplementedError - In case the circuit element is defined in a format which is not supported by this version. If this is the case, use GitHub to start a ticket. https://github.com/nunobrum/spicelib """ if self.is_read_only(): raise ValueError("Editor is read-only") self._set_component_attribute(reference, 'value', value)
[docs] def set_element_model(self, reference: str, model: str) -> None: """Changes the value of a circuit element, such as a diode model or a voltage supply. Usage: :: runner.set_element_model('D1', '1N4148') runner.set_element_model('V1' "SINE(0 1 3k 0 0 0)") :param reference: Reference of the circuit element to be updated. :param model: model name of the device to be updated :raises: ComponentNotFoundError - In case the component is not found ValueError - In case the model format contains irregular characters NotImplementedError - In case the circuit element is defined in a format which is not supported by this version. If this is the case, use GitHub to start a ticket. https://github.com/nunobrum/spicelib """ if self.is_read_only(): raise ValueError("Editor is read-only") super().set_element_model(reference, model) self._set_component_attribute(reference, 'model', model)
[docs] def get_component_value(self, reference: str) -> str: """ Returns the value of a component retrieved from the netlist. :param reference: Reference of the circuit element to get the value. :return: value of the circuit element . :raises: ComponentNotFoundError - In case the component is not found NotImplementedError - for not supported operations """ return self.get_component(reference).value_str
[docs] def get_component_nodes(self, reference: str) -> list[str]: """ Returns the nodes to which the component is attached to. :param reference: Reference of the circuit element to get the nodes. :return: List of nodes """ nodes = self.get_component(reference).ports return nodes
[docs] def get_components(self, prefixes='*') -> list: """ Returns a list of components that match the list of prefixes indicated on the parameter prefixes. In case prefixes is left empty, it returns all the ones that are defined by the REPLACE_REGEXES. The list will contain the designators of all components found. :param prefixes: Type of prefixes to search for. Examples: 'C' for capacitors; 'R' for Resistors; etc... See prefixes in SPICE documentation for more details. The default prefix is '*' which is a special case that returns all components. :type prefixes: str :return: A list of components matching the prefixes demanded. """ answer = [] if prefixes == '*': prefixes = ''.join(REPLACE_REGEXS.keys()) for line in self.netlist: if isinstance(line, SpiceCircuit): # Only gets components from the main netlist, # it currently skips sub-circuits continue elif isinstance(line, ControlEditor): # same for control editor continue tokens = line.split() try: ref = tokens[0].upper() if ref[0] in prefixes: answer.append(ref) # Appends only the designators except IndexError or TypeError: pass return answer
[docs] def add_component(self, component: Component, **kwargs) -> None: """ Adds a component to the netlist. The component is added to the end of the netlist, just before the .END statement. If the component already exists, it will be replaced by the new one. :param component: The component to be added to the netlist :param kwargs: The following keyword arguments are supported: * **insert_before** (str) - The reference of the component before which the new component should be inserted. * **insert_after** (str) - The reference of the component after which the new component should be inserted. :return: Nothing """ if self.is_read_only(): raise ValueError("Editor is read-only") if 'insert_before' in kwargs: line_no = self.get_line_starting_with(kwargs['insert_before']) elif 'insert_after' in kwargs: line_no = self.get_line_starting_with(kwargs['insert_after']) + 1 else: # Insert before backanno instruction try: line_no = self.netlist.index( '.backanno\n') # TODO: Improve this. END of line termination could be differnt except ValueError: line_no = len(self.netlist) - 2 nodes = " ".join(component.ports) # The code below is somewhat superfluous at the moment: # Model and Value are used interchangeably and stored as Value. # But this is what would be needed probably: # model = component.attributes.get('model', None) # if model is None: # model = '' # else: # model = f" {model}" model = '' value = component.attributes.get('value', None) if value is not None: if isinstance(value, (int, float)): value = format_eng(value) value = f" {value}" else: value = '' if ('params' in component.attributes) and (isinstance(component.attributes['params'], dict)): # Merge params into the main attributes so that they are added to the line parameters = " " + " ".join([f"{k}={v}" for k, v in component.attributes['params'].items()]) else: parameters = '' component_line = f"{component.reference} {nodes}{model}{value}{parameters}{END_LINE_TERM}" self.netlist.insert(line_no, component_line) super().add_component(component)
[docs] def remove_component(self, designator: str) -> None: """ Removes a component from the design. Current implementation only allows removal of a component from the main netlist, not from a sub-circuit. :param designator: Component reference in the design. Ex: V1, C1, R1, etc... :return: Nothing :raises: ComponentNotFoundError - When the component doesn't exist on the netlist. """ if self.is_read_only(): raise ValueError("Editor is read-only") line = self.get_line_starting_with(designator) self.netlist[line] = '' # Blanks the line super().remove_component(designator)
[docs] @staticmethod def add_library_search_paths(*paths) -> None: """ .. deprecated:: 1.1.4 Use the class method `set_custom_library_paths()` instead. Adds search paths for libraries. By default, the local directory and the ~username/"Documents/LTspiceXVII/lib/sub will be searched forehand. Only when a library is not found in these paths then the paths added by this method will be searched. :param paths: Path to add to the Search path :type paths: str :return: Nothing """ SpiceCircuit.set_custom_library_paths(*paths)
[docs] def get_all_nodes(self) -> list[str]: """ Retrieves all nodes existing on a Netlist. :returns: Circuit Nodes """ circuit_nodes = [] for line in self.netlist: prefix = get_line_command(line) if prefix in component_replace_regexs: match = component_replace_regexs[prefix].match(line) if match: nodes = match.group('nodes').split() # This separates by all space characters including \t for node in nodes: if node not in circuit_nodes: circuit_nodes.append(node) return circuit_nodes
[docs] def save_netlist(self, run_netlist_file: str | Path | io.StringIO) -> None: # docstring is in the parent class pass
[docs] def add_instruction(self, instruction: str) -> None: # docstring is in the parent class super().add_instruction(instruction)
[docs] def remove_instruction(self, instruction: str) -> bool: # docstring is in the parent class return False
[docs] def remove_Xinstruction(self, search_pattern: str) -> bool: # docstring is in the parent class return False
@property def circuit_file(self) -> Path: """ Returns the path of the circuit file. Always returns an empty Path for SpiceCircuit. """ return Path('')
[docs] def is_read_only(self) -> bool: """Check if the component can be edited. This is useful when the editor is used on non modifiable files. :return: True if the component is read-only, False otherwise """ return self._readonly
@staticmethod def find_subckt_in_lib(library: str, subckt_name: str) -> SpiceCircuit | None: """ Finds a sub-circuit in a library. The search is case-insensitive. :param library: path to the library to search :param subckt_name: sub-circuit to search for :return: Returns a SpiceCircuit instance with the sub-circuit found or None if not found :meta private: """ # 0. Setup things reg_subckt = re.compile(SUBCKT_CLAUSE_FIND + subckt_name, re.IGNORECASE) # 1. Find Encoding try: encoding = detect_encoding(library, r"[\* a-zA-Z]") except EncodingDetectError: return None # 2. scan the file with open(library, encoding=encoding) as lib: for line in lib: search = reg_subckt.match(line) if search: sub_circuit = SpiceCircuit() sub_circuit.netlist.append(line) # Advance to the next non nested .ENDS finished = sub_circuit._add_lines(lib) if finished: # if this is from a lib, don't allow modifications sub_circuit._readonly = True return sub_circuit # 3. Return an instance of SpiceCircuit return None def find_subckt_in_included_libs(self, subcircuit_name: str) -> SpiceCircuit | None: """Find the subcircuit in the list of libraries :param subcircuit_name: sub-circuit to search for :return: Returns a SpiceCircuit instance with the sub-circuit found or None if not found :meta private: """ for line in self.netlist: if isinstance(line, SpiceCircuit): # If it is a sub-circuit it will simply ignore it. continue elif isinstance(line, ControlEditor): # same for control editor continue m = lib_inc_regex.match(line) if m: # If it is a library include lib = m.group(2) lib_filename = search_file_in_containers(lib, os.path.split(self.circuit_file)[0], # The directory where the file is located os.path.curdir, # The current script directory, *self.simulator_lib_paths, # The simulator's library paths *self.custom_lib_paths) # The custom library paths if lib_filename: sub_circuit = SpiceEditor.find_subckt_in_lib(lib_filename, subcircuit_name) if sub_circuit: # Success we can go out # by the way, this circuit will have been marked as readonly return sub_circuit if self.parent is not None: # try searching on parent netlists return self.parent.find_subckt_in_included_libs(subcircuit_name) else: return None
class ControlEditor: """ Provides interfaces to manipulate SPICE `.control` instructions. """ def __init__(self, parent: SpiceCircuit = None): self._content = "" self.parent = parent def _add_lines(self, line_iter): """Internal function. Do not use. Add a list of lines to the section. No parsing, just loop until a .ENDC is found.""" self._content = self._content.rstrip() + END_LINE_TERM for line in line_iter: self._content += line.rstrip() + END_LINE_TERM if line.strip().upper().startswith(".ENDC"): return True return False # If a file ends abruptly, returns False def _write_lines(self, f): """Internal function. Do not use.""" # This helper function writes the contents of the section to the file f f.write(self._content) @property def content(self) -> str: """The content as a string :getter: Returns the value as a string """ return self._content @content.setter def content(self, value: str): """Sets the content of the ControlEditor to the given value. :param value: The new content to be set """ self._content = value.strip() + END_LINE_TERM
[docs] class SpiceEditor(SpiceCircuit): """ Provides interfaces to manipulate SPICE netlist files. The class doesn't update the netlist file itself. After implementing the modifications the user should call the "save_netlist" method to write a new netlist file. :param netlist_file: Name of the .NET file to parse :type netlist_file: str or pathlib.Path :param encoding: Forcing the encoding to be used on the circuit netlile read. Defaults to 'autodetect' which will call a function that tries to detect the encoding automatically. This, however, is not 100% foolproof. :type encoding: str, optional :param create_blank: Create a blank '.net' file when 'netlist_file' not exist. False by default :type create_blank: bool, optional """ def __init__(self, netlist_file: str | Path, encoding='autodetect', create_blank=False): super().__init__() self.netlist_file = Path(netlist_file) if create_blank: self.encoding = 'utf-8' # when user want to create a blank netlist file, and didn't set encoding. else: if encoding == 'autodetect': try: self.encoding = detect_encoding(self.netlist_file, r'^\*') # Normally the file will start with a '*' except EncodingDetectError as err: raise err else: self.encoding = encoding self.reset_netlist(create_blank) @property def circuit_file(self) -> Path: # docstring inherited from BaseSchematic return self.netlist_file
[docs] def add_instruction(self, instruction: str) -> None: """Adds a SPICE instruction to the netlist. For example: .. code-block:: text .tran 10m ; makes a transient simulation .meas TRAN Icurr AVG I(Rs1) TRIG time=1.5ms TARG time=2.5ms ; Establishes a measuring .step run 1 100, 1 ; makes the simulation run 100 times .control ... control statements on multiple lines ... .endc :param instruction: Spice instruction to add to the netlist. This instruction will be added at the end of the netlist, typically just before the .BACKANNO statement :return: Nothing """ super().add_instruction(instruction) if not instruction.endswith(END_LINE_TERM): instruction += END_LINE_TERM cmd = get_line_command(instruction) if _is_unique_instruction(cmd): # Before adding new instruction, delete previously set unique instructions i = 0 while i < len(self.netlist): line = self.netlist[i] if _is_unique_instruction(line): self.netlist[i] = instruction break else: i += 1 elif cmd == '.PARAM': raise RuntimeError('The .PARAM instruction should be added using the "set_parameter" method') # check whether the instruction is already there (dummy proofing) # TODO: if adding a .MODEL or .SUBCKT it should verify if it already exists and update it. if instruction not in self.netlist: # Insert at the end line = len(self.netlist) - 1 # If there is .backanno, then it will be added just before that statement for nr, linecontent in enumerate(self.netlist): if isinstance(linecontent, str): if linecontent.lower().startswith('.backanno'): line = nr break if cmd == ".CONTROL": # If it is a control instruction, then it should be added as a ControlEditor c = ControlEditor(self) c.content = instruction self.netlist.insert(line, c) else: self.netlist.insert(line, instruction)
[docs] def remove_instruction(self, instruction) -> bool: # docstring is in the parent class # TODO: Make it more intelligent so it recognizes .models, .param and .subckt # Because the netlist is stored containing the end of line terminations and because they are added when they # they are added to the netlist. if not instruction.endswith(END_LINE_TERM): instruction += END_LINE_TERM i = 0 for line in self.netlist: if isinstance(line, SpiceCircuit): # If it is a sub-circuit it will simply ignore it. i += 1 continue elif isinstance(line, ControlEditor): # compare contents line = line.content if line.strip() == instruction.strip(): del self.netlist[i] logtxt = instruction.strip().replace("\r", "\\r").replace("\n", "\\n") _logger.info(f'Instruction "{logtxt}" removed') self.add_update('INSTRUCTION', logtxt, UpdateType.DeleteInstruction) return True i += 1 _logger.error(f'Instruction "{instruction}" not found.') return False
[docs] def remove_Xinstruction(self, search_pattern: str) -> bool: # docstring is in the parent class regex = re.compile(search_pattern, re.IGNORECASE) i = 0 instr_removed = False while i < len(self.netlist): line = self.netlist[i] if isinstance(line, str) and (match := regex.match(line)): del self.netlist[i] instr_removed = True self.add_update('INSTRUCTION', match.string.strip(), UpdateType.DeleteInstruction) _logger.info(f'Instruction "{line}" removed') else: i += 1 if instr_removed: return True else: _logger.error(f'No instruction matching pattern "{search_pattern}" was found') return False
[docs] def save_netlist(self, run_netlist_file: str | Path | io.StringIO) -> None: # docstring is in the parent class if isinstance(run_netlist_file, str): run_netlist_file = Path(run_netlist_file) if isinstance(run_netlist_file, Path): f = open(run_netlist_file, 'w', encoding=self.encoding) else: f = run_netlist_file try: for line in self.netlist: if isinstance(line, SpiceCircuit): line._write_lines(f) elif isinstance(line, ControlEditor): # same for control editor line._write_lines(f) else: # Writes the modified sub-circuits at the end just before the .END clause if line.upper().startswith(".END"): # write here the modified sub-circuits for sub in self.modified_subcircuits.values(): sub._write_lines(f) f.write(line) finally: if not isinstance(f, io.StringIO): f.close()
[docs] def get_control_sections(self) -> list[str]: """ Returns a list representing the control sections in the netlist. Control sections are all anonymous, so they do not have a name, just an index. They are also not parsed, they are just a list of strings (with embedded newlines). :return: list of control section strings. These strings have each multiple lines, start with ``.CONTROL`` and end with ``.ENDC``. """ control_sections = [] for line in self.netlist: if isinstance(line, ControlEditor): control_sections.append(line.content) return control_sections
[docs] def add_control_section(self, instruction: str) -> None: """ Adds a control section to the netlist. The instruction should be a multi-line string that starts with '.CONTROL' and ends with '.ENDC'. It will be added as a ControlEditor object to the netlist. You can also use the `add_instruction()` method, but that method has less checking of the format. :param instruction: control section instruction :raises ValueError: if the instruction does not start with ``.CONTROL`` or does not end with ``.ENDC`` """ instruction = instruction.strip() if not instruction.upper().startswith('.CONTROL') or not instruction.upper().endswith('.ENDC'): raise ValueError("Control section must start with '.CONTROL' and end with '.ENDC'") self.add_instruction(instruction)
[docs] def remove_control_section(self, index: int = 0) -> bool: """ Removes a control section from the netlist, based on the index in `get_control_sections()`. You can also use `remove_instruction()`, but there, the given text must match the entire control section. :param index: index of the control section to remove, according to `get_control_sections()` :returns: True if the control section was found and removed, False otherwise """ if index < 0: raise IndexError("Control section index out of range") i = 0 for nr, line in enumerate(self.netlist): if isinstance(line, ControlEditor): if i == index: del self.netlist[nr] logtxt = line.content.replace("\r", "\\r").replace("\n", "\\n") self.add_update('INSTRUCTION', logtxt, UpdateType.DeleteInstruction) _logger.info(f"Control section {index} removed") return True i += 1 _logger.error(f"Control section {index} was not found") return False
[docs] def reset_netlist(self, create_blank: bool = False) -> None: """ Removes all previous edits done to the netlist, i.e. resets it to the original state. :returns: Nothing """ super().reset_netlist(create_blank) self.modified_subcircuits.clear() if create_blank: lines = ['* netlist generated from spicelib', '.end'] finished = self._add_lines(lines) if not finished: raise SyntaxError("Netlist with missing .END or .ENDS statements") elif self.netlist_file.exists(): with open(self.netlist_file, encoding=self.encoding, errors='replace') as f: lines = iter(f) # Creates an iterator object to consume the file finished = self._add_lines(lines) if not finished: raise SyntaxError("Netlist with missing .END or .ENDS statements") # else: # for _ in lines: # Consuming the rest of the file. # pass # print("Ignoring %s" % _) else: _logger.error(f"Netlist file not found: {self.netlist_file}")
[docs] def run(self, wait_resource: bool = True, callback: Callable[[str, str], Any] = None, timeout: float = None, run_filename: str = None, simulator=None): """ .. deprecated:: 1.0 Use the `run` method from the `SimRunner` class instead. Convenience function for maintaining legacy with legacy code. Runs the SPICE simulation. """ from ..sim.sim_runner import SimRunner runner = SimRunner(simulator=simulator) return runner.run(self, wait_resource=wait_resource, callback=callback, timeout=timeout, run_filename=run_filename)