Source code for spicelib.editor.qsch_editor

# -------------------------------------------------------------------------------
#
#  ███████╗██████╗ ██╗ ██████╗███████╗██╗     ██╗██████╗
#  ██╔════╝██╔══██╗██║██╔════╝██╔════╝██║     ██║██╔══██╗
#  ███████╗██████╔╝██║██║     █████╗  ██║     ██║██████╔╝
#  ╚════██║██╔═══╝ ██║██║     ██╔══╝  ██║     ██║██╔══██╗
#  ███████║██║     ██║╚██████╗███████╗███████╗██║██████╔╝
#  ╚══════╝╚═╝     ╚═╝ ╚═════╝╚══════╝╚══════╝╚═╝╚═════╝
#
# Name:        qsch_editor.py
# Purpose:     Class made to update directly the QSPICE Schematic files
#
# Author:      Nuno Brum (nuno.brum@gmail.com)
#
# License:     refer to the LICENSE file
# -------------------------------------------------------------------------------
import math
import os
import sys
import io
from collections import OrderedDict
from pathlib import Path
from typing import TextIO, Any
import re
import logging

from .base_subcircuit import BaseSubCircuit
from .primitives import format_eng
from .editor_errors import ComponentNotFoundError, ParameterNotFoundError
from .base_editor import PARAM_REGEX, ValueType
from .spice_utils import UNIQUE_SIMULATION_DOT_INSTRUCTIONS
from .base_schematic import (BaseSchematic, SchematicComponent, Point, ERotation, Line, Text, TextTypeEnum,
                             LineStyle, Shape)
from .updates import UpdateType, UpdatePermission
from ..simulators.qspice_simulator import Qspice
from ..utils.file_search import search_file_in_containers

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

__all__ = ('QschEditor', 'QschTag', 'QschReadingError')

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

QSCH_HEADER = (255, 216, 255, 219)


# «component (-1200,-100) 0 0
QSCH_COMPONENT_POS = 1
QSCH_COMPONENT_ROTATION = 2
QSCH_COMPONENT_ENABLED = 3
#    «symbol V
#       «type: V»
#       «description: Independent Voltage Source»
#       «shorted pins: false»
#       ... primitives : rect, line, zigzag, elipse, etc...
#       «text (100,150) 1 7 0 0x1000000 -1 -1 "R1"»
#       «text (100,-150) 1 7 0 0x1000000 -1 -1 "100K"»
QSCH_SYMBOL_TEXT_REFDES = 0
QSCH_SYMBOL_TEXT_VALUE = 1
#       «pin (0,200) (0,0) 1 0 0 0x0 -1 "+"»
QSCH_SYMBOL_PIN_POS1 = 1
QSCH_SYMBOL_PIN_POS2 = 2
QSCH_SYMBOL_PIN_NET = 8
QSCH_SYMBOL_PIN_NET_BEHAVIORAL = 9
#    »
# »
#   «wire (-1200,100) (-500,100) "N01"»
QSCH_WIRE_POS1 = 1
QSCH_WIRE_POS2 = 2
QSCH_WIRE_NET = 3

# «net (<x>,<y>) <s> <l> <p> "<netname>"»
# (<x>,<y>) - Location of then Net identifier
# <s> - Font Size (1 is default)
# <l> - Location 7=Right 11=Left 13=Bottom 14=Top
#        7 0111
#       11 1011
#       13 1101
#       14 1110
# <p> - 0=Net , 1=Port
QSCH_NET_POS = 1
QSCH_NET_ROTATION = "?"
QSCH_NET_STR_ATTR = 5

#   «text (-800,-650) 1 77 0 0x1000000 -1 -1 ".tran 5m"»
QSCH_TEXT_POS = 1
QSCH_TEXT_SIZE = 2
QSCH_TEXT_ROTATION = 3  # 13="0 Degrees" 45="90 Degrees" 77="180 Degrees" 109="270 Degrees" r= 13+32*alpha/90
QSCH_TEXT_COMMENT = 4  # 0="Normal Text" 1="Comment"
QSCH_TEXT_COLOR = 5  # 0xdbbggrr  d=1 "Default" rr=Red gg=Green bb=Blue in hex format
QSCH_TEXT_STR_ATTR = 8

QSCH_TEXT_INSTR_QUALIFIER = ""

# «line (2000,1300) (3150,-100) 1 1 0xff0000 -1 -1»
QSCH_LINE_POS1 = 1
QSCH_LINE_POS2 = 2
QSCH_LINE_WIDTH = 3  # 0=Default 1=Thinnest ... 7=Thickest
QSCH_LINE_TYPE = 4  # 0=Normal 1=Dashed 2=Dotted 3=DashDot 4=DashDotDot
QSCH_LINE_COLOR = 5
QSCH_LINE_UNKNOWN1 = 6
QSCH_LINE_UNKNOWN2 = 7

# «rect (1850,1550) (3650,-400) 0 0 0 0x8000 0x1000000 -1 0 -1»
QSCH_RECT_POS1 = 1
QSCH_RECT_POS2 = 2
QSCH_RECT_UNKNOWN0 = 3
QSCH_RECT_LINE_WIDTH = 4
QSCH_RECT_LINE_TYPE = 5  # 0=Normal 1=Dashed 2=Dotted 3=DashDot 4=DashDotDot
QSCH_RECT_LINE_COLOR = 6
QSCH_RECT_FILL_COLOR = 7
QSCH_RECT_UNKNOWN1 = 8
QSCH_RECT_UNKNOWN2 = 9
QSCH_RECT_UNKNOWN3 = 10

#  «ellipse (2100,1150) (2650,150) 0 0 2 0xff0000 0x1000000 -1 -1»
QSCH_ELLIPSE_POS1 = 1
QSCH_ELLIPSE_POS2 = 2
QSCH_ELLIPSE_UNKNOWN0 = 3
QSCH_ELLIPSE_WIDTH = 4
QSCH_ELLIPSE_LINE_TYPE = 5  # 0=Normal 1=Dashed 2=Dotted 3=DashDot 4=DashDotDot
QSCH_ELLIPSE_LINE_COLOR = 6
QSCH_ELLIPSE_FILL_COLOR = 7
QSCH_ELLIPSE_UNKNOWN1 = 8
QSCH_ELLIPSE_UNKNOWN2 = 9

# «arc3p (2700,300) (2250,1200) (2500,800) 0 2 0xff0000 -1 -1»
QSCH_ARC3P_POS1 = 1
QSCH_ARC3P_POS2 = 2
QSCH_ARC3P_POS3 = 3
QSCH_ARC3P_UNKNOWN0 = 4
QSCH_ARC3P_WIDTH = 5
QSCH_ARC3P_LINE_COLOR = 6
QSCH_ARC3P_UNKNOWN1 = 7
QSCH_ARC3P_UNKNOWN2 = 8

# «triangle (3050,1250) (3550,700) (3450,1400) 0 2 0xff0000 0x2000000 -1 -1»
QSCH_TRIANGLE_POS1 = 1
QSCH_TRIANGLE_POS2 = 2
QSCH_TRIANGLE_POS3 = 3
QSCH_TRIANGLE_UNKNOWN0 = 4
QSCH_TRIANGLE_LINE_TYPE = 5  # 0=Normal 1=Dashed 2=Dotted 3=DashDot 4=DashDotDot
QSCH_TRIANGLE_LINE_COLOR = 6
QSCH_TRIANGLE_FILL_COLOR = 7
QSCH_TRIANGLE_UNKNOWN1 = 8
QSCH_TRIANGLE_UNKNOWN2 = 9

# «coil (3050,400) (3450,600) 0 0 2 0xff0000 -1 -1»
QSCH_COIL_POS1 = 1
QSCH_COIL_POS2 = 2
QSCH_COIL_UNKNOWN0 = 3
QSCH_COIL_WIDTH = 4
QSCH_COIL_LINE_TYPE = 5  # 0=Normal 1=Dashed 2=Dotted 3=DashDot 4=DashDotDot
QSCH_COIL_LINE_COLOR = 6
QSCH_COIL_UNKNOWN1 = 7
QSCH_COIL_UNKNOWN2 = 8

# «zigzag (3050,250) (3400,100) 0 0 2 0xff0000 -1 -1»
QSCH_ZIGZAG_POS1 = 1
QSCH_ZIGZAG_POS2 = 2
QSCH_ZIGZAG_UNKNOWN0 = 3
QSCH_ZIGZAG_WIDTH = 4
QSCH_ZIGZAG_LINE_TYPE = 5  # 0=Normal 1=Dashed 2=Dotted 3=DashDot 4=DashDotDot
QSCH_ZIGZAG_LINE_COLOR = 6
QSCH_ZIGZAG_UNKNOWN1 = 7
QSCH_ZIGZAG_UNKNOWN2 = 8



def decap(s: str) -> str:
    """Take the leading < and ending > from the parameter value on a string with the format "param=<value>"
    If they are not there, the string is returned unchanged."""
    regex = re.compile(r"(\w+)=<(.*)>")
    return regex.sub(r"\1=\2", s)


def smart_split(s):
    """Splits a string into chunks based on spaces. What is inside "" is not divided."""
    return re.findall(r'[^"\s]+|"[^"]*"', s)


class QschReadingError(IOError):
    ...


class QschTag:
    """
    Class to represent a tag in a QSCH file. It is a recursive class, so it can have children tags.
    """

    def __init__(self, *tokens):
        self.items = []
        self.tokens = []
        if tokens:
            for token in tokens:
                self.tokens.append(str(token))

    @classmethod
    def parse(cls, stream: str, start: int = 0) -> tuple['QschTag', int]:
        """
        Parses a tag from the stream starting at the given position. The stream should be a string.

        :param stream: The string to be parsed
        :param start: The position to start parsing
        :return: A tuple with the tag and the position after the tag
        """
        self = cls()
        assert stream[start] == '«'
        i = start + 1
        i0 = i
        while i < len(stream):
            if stream[i] == '«':
                child, i = QschTag.parse(stream, i)
                i0 = i + 1
                self.items.append(child)
            elif stream[i] == '"':
                # get all characters until the next " sign
                i += 1
                while stream[i] != '"':
                    i += 1
            elif stream[i] == '»':
                stop = i + 1
                break
            elif stream[i] == '\n':
                if i > i0:
                    tokens = smart_split(stream[i0:i])
                    self.tokens.extend(tokens)
                i0 = i + 1
            i += 1
        else:
            raise OSError("Missing » when reading file")
        line = stream[i0:i]
        # Now dividing the
        if ': ' in line:
            name, text = line.split(': ')
            self.tokens.append(name + ":")
            self.tokens.append(text)
        else:
            self.tokens.extend(smart_split(line))
        return self, stop

    def __str__(self):
        """Returns only the first line of the tag. The children are not shown."""
        return ' '.join(self.tokens)

    def out(self, level):
        """
        Returns a string representation of the tag with the specified indentation.

        :param level: The indentation level
        :return: A string representation of the tag
        """
        spaces = '  ' * level
        if len(self.items):
            return (f"{spaces}«{' '.join(self.tokens)}\n"
                    f"{''.join(tag.out(level + 1) for tag in self.items)}"
                    f"{spaces}»\n")
        else:
            return f"{'  ' * level}«{' '.join(self.tokens)}»\n"

    @property
    def tag(self) -> str:
        """Returns the tag id of the object. The tag id is the first token in the tag."""
        return self.tokens[0]
    
    def get_items(self, item) -> list['QschTag']:
        """Returns a list of children tags that match the given tag id."""
        answer = [tag for tag in self.items if tag.tag == item]
        return answer

    def get_attr(self, index: int):
        """
        Returns the attribute at the given index. The attribute can be a string, an integer or a tuple.
        The return type depends on the attribute being read.
        If the attribute is between quotes, it returns a string.
        If it is between parenthesis, it returns a tuple of integers.
        If it starts with "0x", it returns an integer representing the following hexadecimal number.
        Otherwise, it returns an integer.

        :param index: The index of the attribute to be read
        :return: The attribute at the given index
        """
        a = self.tokens[index]
        if a.startswith('(') and a.endswith(')'):
            return tuple(int(x) for x in a[1:-1].split(','))
        elif a.startswith('0x'):
            return int(a[2:], 16)
        elif a.startswith('"') and a.endswith('"'):
            return a[1:-1]
        else:
            try:
                value = int(a)
            except ValueError:
                try:
                    value = float(a)
                except ValueError:
                    value = a
            return value

    def set_attr(self, index: int, value: str | int | tuple[Any, Any]):
        """Sets the attribute at the given index. The attribute can be a string, an integer or a tuple.
        Integer values are written as integers, strings are written between quotes unless it starts with "0x"
        and tuples are written between parenthesis.

        :param index: The index of the attribute to be set
        :param value: The value to be set
        :return: Nothing
        """
        if isinstance(value, int):
            value_str = str(value)
        elif isinstance(value, str):
            if value.startswith('0x'):
                value_str = value
            else:
                value_str = f'"{value}"'
        elif isinstance(value, tuple):
            value_str = f'({value[0]},{value[1]})'
        else:
            raise ValueError("Object not supported in set_attr")
        self.tokens[index] = value_str

    def get_text(self, label: str, default: str | None = None) -> str:
        """
        Returns the text of the first child tag that matches the given label. The label can have up to 1 space in it.
        It will return the entire text of the tag, after the label.
        If the label is not found, it returns the default value.

        :param label: label to be found. Can have up to 1 space (e.g. "library file" or "shorted pins")
        :param default: Default value, defaults to None
        :raises IndexError: When the label is not found and the default value is None
        :return: the found text or the default value
        """
        a = self.get_items(label + ':')
        if len(a) != 1:
            if default is None:
                raise IndexError(f"Label '{label}' not found in {self}")
            else:
                return default
        if len(a[0].tokens) >= 2:
            return a[0].tokens[1]
        else:
            return default or ""

    def get_text_attr(self, index: int) -> str:
        """Returns the text of the attribute at the given index. Unlike get_attr, this method only returns strings."""
        a = self.tokens[index]
        if a.startswith('"') and a.endswith('"'):
            return a[1:-1]
        else:
            return a

    def get_xy_for_new_text(self):
        """Returns a coordinate for a new text."""
        X = None
        Y = None
        for tag in self.items:
            if tag.tag == "text":
                pos = tag.get_attr(QSCH_TEXT_POS)
                if X is None:
                    X = int(pos[0])
                else:
                    X = min(int(pos[0]), X)

                if Y is None:
                    Y = int(pos[1])
                else:
                    Y = max(int(pos[1]), Y)
        if X is None:
            X = 0
        if Y is None:
            Y = 0
        else:
            Y += 150
        return (X, Y)


class QschComponent(SchematicComponent):
    """Class to represent a component in a QSCH file. It is a subclass of SchematicComponent."""

    def __init__(self, parent: 'QschEditor', tag: QschTag, **kwargs):
        super().__init__(parent, tag, **kwargs)

    @property
    def tag(self) -> QschTag:
        return self._obj # pyright: ignore[reportReturnType]

    @property
    def symbol_tag(self) -> QschTag:
        return self.tag.get_items('symbol')[0]

    def reset_attributes(self):
        pass # TODO: Place here the code that is below on the _parse_qsch_stream function

    def set_value(self, value: str | int | float) -> None:
        # docstring inherited from BaseEditor
        if isinstance(value, str):
            value_str = value
        else:
            value_str = format_eng(value)
        self.set_model(value_str)

    def set_model(self, model: str) -> None:
        parent : QschEditor = self.parent # pyright: ignore[reportAssignmentType]
        permission = parent.begin_update()
        if permission == UpdatePermission.Deny:
            raise PermissionError("Editor is read-only")
        texts = self.symbol_tag.get_items('text')
        assert texts[QSCH_SYMBOL_TEXT_REFDES].get_attr(QSCH_TEXT_STR_ATTR) == self.reference
        texts[QSCH_SYMBOL_TEXT_VALUE].set_attr(QSCH_TEXT_STR_ATTR, model)
        self.attributes['value'] = model
        _logger.info(f"Component {self.reference} updated to {model}")
        if permission == UpdatePermission.Inform:
            reference : str = self.reference
            parent.end_update(reference, model, UpdateType.UpdateComponentValue)

    def get_parameters(self) -> OrderedDict:
        """
        Returns the parameters of the component in a dictionary. Since QSpice stores attributes by their order of
        appearance on the QSCH file, some parameters may not be found if they are not in the standard format.
        If a line contains a parameter definition that is on the standard format, it will be parsed and stored in the
        dictionary. The key of the dictionary is the line number where the parameter was found.

        :return: A dictionary with the parameters of the component
        :rtype: dict
        """
        texts = self.symbol_tag.get_items('text')
        parameters = OrderedDict()
        param_regex = re.compile(PARAM_REGEX(r'\w+'), re.IGNORECASE)
        for i in range(2, len(texts)):
            text : str = texts[i].get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType]
            matches = param_regex.finditer(text)
            for match in matches:
                parameters[match.group('name')] = match.group('value')
            else:
                parameters[i] = text

        return parameters

    def set_parameter(self, key: str, value: float | str):
        self.set_parameters(**{key: value})

    def set_parameters(self, **kwargs) -> None:
        """
        Sets the parameters of the component. If key parameters that are integers, they represent the line number
        where the parameter was found. If the key is a string, it represents the parameter name. If the parameter name
        already exists, it will be replaced. If not found, it will be added as a new text line.
        """
        parent : QschEditor = self.parent # pyright: ignore[reportAssignmentType]
        permission = parent.begin_update()
        if permission == UpdatePermission.Deny:
            raise PermissionError("Editor is read-only")
        symbol = self.symbol_tag
        texts = symbol.get_items('text')

        for key, value in kwargs.items():
            if isinstance(value, str) or value is None:
                value_str = value
            else:
                value_str = format_eng(value)
            if isinstance(key, int):
                if key < 2 or key > len(texts):
                    raise ValueError(f"Invalid line number {key} for component {self.reference}")
                if key == len(texts):
                    x, y = 0, 0  # TODO: Find a way to get the position of the last text
                    tag, _ = QschTag.parse(
                        f'«text ({x},{y}) 1 7 0 0x1000000 -1 -1 "{value}"»'
                    )
                    symbol.items.append(tag)

                else:
                    texts[key].set_attr(QSCH_TEXT_STR_ATTR, value_str)
                if permission == UpdatePermission.Inform:
                    reference : str = self.reference # pyright: ignore[reportAssignmentType]
                    parent.end_update(reference, value_str, UpdateType.UpdateComponentParameter)
            else:
                found = False
                search_expression = re.compile(PARAM_REGEX(r"\w+"), re.IGNORECASE)
                for text in texts[QSCH_SYMBOL_TEXT_VALUE:]:
                    text_value: str = text.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType]

                    for match in search_expression.finditer(text_value):
                        if match.group("name") == key:
                            if value is None:
                                # Remove the entire parameter definition from the text
                                start, stop = match.span()
                                if stop - start == len(text_value):
                                    # The whole tag needs to be deleted
                                    symbol.items.remove(text)
                                else:
                                    # delete only the parameter definition
                                    text_value = text_value[:start] + text_value[stop:]
                                    text.set_attr(QSCH_TEXT_STR_ATTR, text_value)
                                update = UpdateType.DeleteComponentParameter
                            else:
                                start, stop = match.span("value")
                                text_value = text_value[:start] + value_str + text_value[stop:]
                                text.set_attr(QSCH_TEXT_STR_ATTR, text_value)
                                update = UpdateType.UpdateComponentParameter
                            if permission == UpdatePermission.Inform:
                                reference : str = self.reference # pyright: ignore[reportAssignmentType]
                                parent.end_update(f'{reference}:{key}', value_str, update)
                            found = True
                        if found:
                            break
                    if found:
                        break
                if not found and value is not None:
                    x, y = symbol.get_xy_for_new_text()
                    new_tag, _ = QschTag.parse(
                        f'«text ({x},{y}) 0.5 0 0 0x1000000 -1 -1 "{key}={value_str}"»'
                    )
                    # Inserting the new tag just after the last text and the first pin
                    last_text = 0
                    for i, tag in enumerate(symbol.items):
                        if tag.tag == 'pin':
                            last_text = i
                            break
                        elif tag.tag == 'text':
                            last_text = i + 1  # The new text should be inserted after the last text
                    if 0 <= last_text < len(symbol.items):
                        symbol.items.insert(last_text, new_tag)
                    else:
                        symbol.items.append(new_tag)
                    if permission == UpdatePermission.Inform:
                        reference : str = self.reference # pyright: ignore[reportAssignmentType]
                        parent.end_update(f'{reference}:{key}', value_str, UpdateType.AddComponentParameter)
                    parent.canvas_updated = True

    def get_position(self) -> tuple[Point, ERotation]:
        return self.position, self.rotation

    def set_position(self,
         position: Point | tuple,
         rotation: ERotation | int,
         mirror: bool = False,
         ) -> None:
        # docstring inherited from BaseSchematic

        comp_tag: QschTag = self.tag
        if isinstance(position, tuple):
            position = Point(position[0], position[1])
        elif isinstance(position, Point):
            pass
        else:
            raise ValueError("Invalid position object")
        if isinstance(rotation, ERotation):
            rot = rotation.value / 45
        elif isinstance(rotation, int):
            rot = (rotation % 360) // 45
            if mirror:
                rot += 8
        else:
            raise ValueError("Invalid rotation parameter")

        comp_tag.set_attr(QSCH_COMPONENT_POS, (position.X, position.Y))
        comp_tag.set_attr(QSCH_COMPONENT_ROTATION, rot)
        self.position = position
        self.rotation = rotation if isinstance(rotation, ERotation) else ERotation(rotation)


[docs] class QschEditor(BaseSchematic, BaseSubCircuit): """Class made to update directly QSCH files. It is a subclass of BaseSchematic, so it can be used to update the netlist and the parameters of the simulation. It can also be used to update the components. :param qsch_filename: Path to the QSCH file to be edited :keyword create_blank: If True, the file will be created from scratch. If False, the file will be read and parsed """ simulator_lib_paths: list[str] = Qspice.get_default_library_paths() """ This is initialised with typical locations found for QSPICE. 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, qsch_filename: str | Path, **kwargs): super().__init__(qsch_filename) self.schematic = None # read the file into memory self.reset_netlist(**kwargs)
[docs] def save_as(self, qsch_filename: str | Path) -> None: """ Saves the schematic to a QSCH file. The file is saved in cp1252 encoding. :param qsch_filename: The path to the QSCH file to be saved """ if not self.schematic: _logger.error("Empty Schematic information") return qsch_filename_p = Path(qsch_filename) if self.updated() or qsch_filename_p != self._circuit_filepath: self._circuit_filepath = qsch_filename_p with qsch_filename_p.open('w', encoding="cp1252") as qsch_file: _logger.info(f"Writing QSCH file {qsch_file}") for c in QSCH_HEADER: qsch_file.write(chr(c)) qsch_file.write(self.schematic.out(0)) qsch_file.write('\n') # Terminates the new line # now checks if there are subcircuits that need to be saved for component in self.components.values(): if "_SUBCKT" in component.attributes: sub_circuit: QschEditor = component.attributes['_SUBCKT'] if sub_circuit is not None and sub_circuit.updated(): sub_circuit.save_as(sub_circuit.circuit_file)
[docs] def write_spice_to_file(self, netlist_file: TextIO, verilog_config: dict[str, list[str]] = {}): """ Appends the netlist to a file buffer. :param netlist_file: The file buffer to save the netlist :param verilog_config: Mandatory when using Ø components: Verilog modules in a DLL. Details: see `save_netlist()` :return: Nothing """ symbol : str libraries_to_include = [] subcircuits_to_write = OrderedDict() if not self.schematic: _logger.error("Empty Schematic information") return for refdes, component in self.components.items(): component: SchematicComponent item_tag = component.attributes['tag'] disabled = not component.attributes['enabled'] symbol_tags = item_tag.get_items('symbol') if len(symbol_tags) != 1 or disabled: continue symbol_tag = symbol_tags[0] if len(symbol_tag.tokens) > 1: symbol = symbol_tag.get_text_attr(1) typ = symbol_tag.get_text('type') else: typ = symbol_tag.get_text('type', "X") symbol = 'X' if not typ or typ[0] != 'Ø': typ = 'X' else: typ = 'Ø' symbol = component.value # pyright: ignore[reportAssignmentType] if refdes[0] != typ[0]: refdes = typ[0] + '´' + refdes texts = symbol_tag.get_items('text') parameters = "" if len(texts) > 2: for text in texts[2:]: parameters += " " + decap(text.get_text_attr(QSCH_TEXT_STR_ATTR)) ports = component.port_list() if typ in ('¥', 'Ã'): # these 2 types MUST have 16 ports if len(ports) < 16: ports += ['¥'] * (16 - len(ports)) if typ == '€': # this type MUST have 32 ports if len(ports) < 32: ports += ['¥'] * (32 - len(ports)) if typ == '£': # this type MUST have 64 ports if len(ports) < 64: ports += ['¥'] * (64 - len(ports)) # Default nets assignment: just a concatenation of the port names nets = " ".join(ports) model = texts[1].get_text_attr(QSCH_TEXT_STR_ATTR) # Check the libraries and embedded subcircuits library_name = symbol_tag.get_text('library file', default="") if library_name.startswith('|'): # make a regular expression that will prefix the model or subcircuit with {refdes}•{model} new_line = re.sub(r"^\|\.(model|subckt) (\w+) (.*)", fr".\1 {refdes}•\2 \3", library_name, flags=re.MULTILINE) new_line = new_line.replace("\\n", "\n") netlist_file.write(new_line + '\n') model = f"{refdes}{model}" elif library_name and (library_name not in libraries_to_include): # List the libraries at the end libraries_to_include.append(library_name) if typ == 'X': # schedule to write .SUBCKT clauses at the end if model not in subcircuits_to_write: if '_SUBCKT' in component.attributes: pins = symbol_tag.get_items("pin") sub_ports = " ".join(pin.get_attr(QSCH_SYMBOL_PIN_NET) for pin in pins) subcircuits_to_write[model] = ( component.attributes['_SUBCKT'], # the subcircuit schematic is saved sub_ports, # and also storing the port position now, so to save time later. ) nets = " ".join(ports) netlist_file.write(f'{refdes} {nets} {model}{parameters}\n') elif typ in ('QP', 'QN'): if symbol == 'NPNS' or symbol == 'PNPS' or symbol == 'LPNP': ports[3] = '[' + ports[3] + ']' nets = ' '.join(ports) hack = 'PNP' if 'PNP' in symbol else 'NPN' netlist_file.write(f'{refdes} {nets} {model} {hack}{parameters}\n') else: netlist_file.write(f'{refdes} {nets} [0] {model} {symbol}{parameters}\n') elif typ in ('MN', 'MP'): if symbol == 'NMOSB' or symbol == 'PMOSB': symbol = symbol[0:4] if len(ports) == 3: netlist_file.write(f'{refdes} {nets} {ports[2]} {model} {symbol}{parameters}\n') else: netlist_file.write(f'{refdes} {nets} {model} {symbol}{parameters}\n') elif typ == 'T': model = decap(texts[1].get_text_attr(QSCH_TEXT_STR_ATTR)) netlist_file.write(f'{refdes} {nets} {model}{parameters}\n') elif typ in ('JN', 'JP'): if symbol.startswith('Pwr'): # Hack alert. I don't know why the symbol is Pwr symbol = symbol[3:] # remove the Pwr from the symbol netlist_file.write(f'{refdes} {nets} {model} {symbol}{parameters}\n') elif typ == '×': netlist_file.write(f'{refdes} «{nets}» {model}{parameters}\n') elif typ in ('ZP', 'ZN'): netlist_file.write(f'{refdes} {nets} {model} {symbol}{parameters}\n') elif typ == 'Ø': # Verilog module. Group the pin configurations, and annmotate the pins basic_refdes = refdes[2:] if '´' in refdes else refdes if basic_refdes in verilog_config: pin_configs = verilog_config[basic_refdes] if len(pin_configs) < len(ports): _logger.error(f"Verilog component {basic_refdes} has insufficient pin configuration, expected {len(ports)} but got {len(pin_configs)}. Netlist will be wrong.") else: in_ports = "" out_ports = "" common_ports = "" for i in range(0, len(ports)): direction_type = pin_configs[i].split(',') if len(direction_type) != 2: _logger.error(f"Verilog component {basic_refdes} has invalid pin configuration '{pin_configs[i]}' for pin {i+1}. Expected format is 'direction,type'. Netlist will be wrong.") continue direction = direction_type[0].lower() port_type = direction_type[1] if direction in ('in', 'input'): in_ports += f" {ports[i]}´{port_type}" elif direction in ('out', 'output'): out_ports += f" {ports[i]}´{port_type}" elif direction in ('common', 'inout'): common_ports += f" {ports[i]}´{port_type}" else: _logger.error(f"Verilog component {basic_refdes} has unknown pin direction '{direction}' for pin {i+1}. Expected 'in', 'out' or 'common'. Netlist will be wrong.") in_ports = in_ports.strip() out_ports = out_ports.strip() common_ports = common_ports.strip() model = "" netlist_file.write(f'{refdes} «{in_ports}» «{out_ports}» «{common_ports}» {model} {symbol}{parameters}\n') else: _logger.error(f"Verilog component {basic_refdes} used without pin configuration. Netlist will be wrong.") else: netlist_file.write(f'{refdes} {nets} {model}{parameters}\n') for sub_circuit in subcircuits_to_write: sub_circuit_schematic, ports = subcircuits_to_write[sub_circuit] netlist_file.write("\n") netlist_file.write(f".subckt {sub_circuit} {ports}\n") sub_circuit_schematic.write_spice_to_file(netlist_file) netlist_file.write(f".ends {sub_circuit}\n") netlist_file.write("\n") text_tags = self.schematic.get_items('text') for text_tag in text_tags: lines: str = text_tag.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] lines = lines.lstrip(QSCH_TEXT_INSTR_QUALIFIER) for line in lines.split('\\n'): if text_tag.get_attr(QSCH_TEXT_COMMENT) != 1: # Comments are not written to the netlist netlist_file.write(line.strip() + '\n') for library in libraries_to_include: mydir = self.circuit_file.parent.absolute().as_posix() library_path = self._qsch_file_find(library, mydir) if library_path is None: netlist_file.write(f'.lib {library}\n') else: if sys.platform.startswith("win"): from ..utils.windows_short_names import get_short_path_name netlist_file.write(f'.lib {get_short_path_name(os.path.abspath(library_path))}\n') else: netlist_file.write(f'.lib {os.path.abspath(library_path)}\n')
# Note: the .END or .ENDCKT must be inserted by the calling function
[docs] def save_netlist(self, run_netlist_file: str | Path | io.StringIO, verilog_config: dict[str, list[str]] = {}) -> None: """ Saves the current state of the netlist to a .qsh or to a .net or .cir file. :param run_netlist_file: File name of the netlist file. Can be .qsch, .net or .cir :param verilog_config: Mandatory when using Ø components: Verilog modules in a DLL. A dictionary with the component reference designators as keys and a list of pin configurations as values, starting at the first pin (port). Each entry must have the format: "direction,type" where * direction is either "in"/"input", "out"/"output", "common"/"inout" * type is any of the verilog port types, for example "b", "uc", "f" Example: `{"X1": ["in,b", "in,b", "in,uc", "out,uc", "out,b"]}` Here the component "X1" has 5 pins, pin 1 is an input of type "bit" and pin 4 is an output of type "unsigned char". :returns: Nothing """ if self.schematic is None: _logger.error("Empty Schematic information") return if isinstance(run_netlist_file, io.StringIO): netlist_file = run_netlist_file _logger.info("Writing NET file to StringIO buffer") else: if isinstance(run_netlist_file, str): run_netlist_file = Path(run_netlist_file) if run_netlist_file.suffix == '.qsch': return self.save_as(run_netlist_file) elif run_netlist_file.suffix in ('.net', '.cir'): netlist_file = open(run_netlist_file, 'w', encoding="cp1252") else: raise ValueError("Unsupported netlist file extension. Use .qsch, .net or .cir") try: _logger.info(f"Writing NET file {run_netlist_file}") netlist_file.write(f'* {os.path.abspath(self.circuit_file.as_posix())}\n') self.write_spice_to_file(netlist_file, verilog_config) netlist_file.write('.end\n') finally: if not isinstance(run_netlist_file, io.StringIO): netlist_file.close() return None
def _find_pin_position(self, comp_pos, orientation: int, pin: QschTag) -> tuple[int, int]: """Returns the net name at the pin position""" pin_pos :tuple[int,int] = pin.get_attr(1) # pyright: ignore[reportAssignmentType] hyp = (pin_pos[0] ** 2 + pin_pos[1] ** 2) ** 0.5 if orientation % 2: # in 45º rotations the component is 1.414 times larger hyp *= 1.414 if 0 <= orientation <= 7: theta = math.atan2(pin_pos[1], pin_pos[0]) + math.radians(orientation * 45) x = comp_pos[0] + round(hyp * math.cos(theta), -2) # round to multiple of 100 y = comp_pos[1] + round(hyp * math.sin(theta), -2) elif 8 <= orientation <= 15: # The component is mirrored on the X axis theta = math.atan2(pin_pos[1], pin_pos[0]) + math.radians((orientation - 8) * 45) x = comp_pos[0] - round(hyp * math.cos(theta), -2) # round to multiple of 100 y = comp_pos[1] + round(hyp * math.sin(theta), -2) else: raise ValueError(f"Invalid orientation: {orientation}") return x, y def _find_net_at_position(self, x, y) -> str | None: """Returns the net name at the given position""" for net in self.schematic.get_items('net'): # pyright: ignore[reportOptionalMemberAccess] # Connection to ports, grounds and nets if net.get_attr(1) == (x, y): net_name : str = net.get_attr(5) # pyright: ignore[reportAssignmentType] # Found the net return '0' if net_name == 'GND' else net_name for wire in self.schematic.get_items('wire'): # pyright: ignore[reportOptionalMemberAccess] # Connection to wires if wire.get_attr(1) == (x, y) or wire.get_attr(2) == (x, y): net_name : str = wire.get_attr(3) # pyright: ignore[reportAssignmentType] # Found the net return '0' if net_name == 'GND' else net_name return None
[docs] def reset_netlist(self, **kwargs) -> bool: """ If create_blank is True, it creates a blank netlist. If False, it reads the netlist from the file into memory. If the file does not exist, it raises a FileNotFoundError. All previous edits done to the netlist are lost. :key create_blank: If True, the file will be created from scratch. If False, the file will be read and parsed """ super().reset_netlist() if not kwargs.get('create_blank', False): if not self.circuit_file.exists(): raise FileNotFoundError(f"File {self.circuit_file} not found") with open(self.circuit_file, encoding="cp1252") as qsch_file: _logger.info(f"Reading QSCH file {self.circuit_file}") stream = qsch_file.read() self._parse_qsch_stream(stream) else: self.update_permission = UpdatePermission.Inform return True
def _parse_qsch_stream(self, stream): """Parses the QSCH file stream""" self.update_permission = UpdatePermission.Initializing self.components.clear() _logger.debug("Parsing QSCH file") header = tuple(ord(c) for c in stream[:4]) if header != QSCH_HEADER: raise QschReadingError("Missing header. The QSCH file should start with: " + f"{' '.join(f'{c:02X}' for c in QSCH_HEADER)}") schematic, _ = QschTag.parse(stream, 4) self.schematic = schematic highest_net_number = 0 behavior_pin_counter = 0 unconnected_pins = {} # Storing the components that have floating pins for net in self.schematic.get_items('net'): # pyright: ignore[reportOptionalMemberAccess] # Connection to ports, grounds and nets # process nets x, y = net.get_attr(QSCH_NET_POS) # pyright: ignore[reportGeneralTypeIssues] # TODO: Get the remaining attributes Rotation, size, color, etc... # rotation = net.get_attr(QSCH_NET_ROTATION) net_name: str = net.get_attr(QSCH_NET_STR_ATTR) # pyright: ignore[reportAssignmentType] self.labels.append(Text(Point(x, y), net_name, type=TextTypeEnum.LABEL)) for wire in self.schematic.get_items('wire'): # process wires x1, y1 = wire.get_attr(QSCH_WIRE_POS1) # pyright: ignore[reportGeneralTypeIssues] x2, y2 = wire.get_attr(QSCH_WIRE_POS2) # pyright: ignore[reportGeneralTypeIssues] netname: str = wire.get_attr(QSCH_WIRE_NET) # pyright: ignore[reportAssignmentType] # Check if the net is of the format N##, if it is get the net number if netname.startswith('N'): try: net_no = int(netname[1:]) if net_no > highest_net_number: highest_net_number = net_no except ValueError: pass self.wires.append(Line(Point(x1, y1), Point(x2, y2), net=netname)) components = self.schematic.get_items('component') for component in components: have_embedded_subcircuit = False symbol: QschTag = component.get_items('symbol')[0] texts = symbol.get_items('text') if len(texts) < 2: raise RuntimeError(f"Missing texts in component at coordinates {component.get_attr(1)}") refdes: str = texts[QSCH_SYMBOL_TEXT_REFDES].get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] value: str = texts[QSCH_SYMBOL_TEXT_VALUE].get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] sch_comp = QschComponent(self, component, reference=refdes, value=value) x, y = position = component.get_attr(QSCH_COMPONENT_POS) # pyright: ignore[reportGeneralTypeIssues] orientation : int = component.get_attr(QSCH_COMPONENT_ROTATION) # pyright: ignore[reportAssignmentType] sch_comp.position = Point(x, y) sch_comp.rotation = ERotation(orientation * 45) sch_comp.attributes['type'] = symbol.get_text('type', "X") # Assuming a sub-circuit # a bit complicated way to detect embedded subcircuits: they are in the library tag, lib = symbol.get_text('library file', "-") if lib.startswith("|.subckt"): have_embedded_subcircuit = True sch_comp.attributes['description'] = symbol.get_text('description', "No Description") sch_comp.attributes['value'] = value sch_comp.attributes['tag'] = component sch_comp.attributes['enabled'] = component.get_attr(QSCH_COMPONENT_ENABLED) == 0 ports = [] pins = symbol.get_items('pin') for pin in pins: x, y = self._find_pin_position(position, orientation, pin) net = self._find_net_at_position(x, y) # The pins that have "¥" are behavioral pins, they are not connected to any net, they will be connected # to a net later. if refdes[0] in ('¥', 'Ã', '€', '£'): if (len(pin.tokens) > QSCH_SYMBOL_PIN_NET_BEHAVIORAL and pin.get_attr(QSCH_SYMBOL_PIN_NET_BEHAVIORAL) == '¥'): net = '¥' if net is None: hash_key = (x, y) if hash_key in unconnected_pins: net = unconnected_pins[hash_key] else: _logger.info(f"Unconnected pin at {x},{y} in component {refdes}:{pin}") if refdes[0] in ('¥', 'Ã', '€', '£'): # Behavioral pins are not connected net = f{behavior_pin_counter:d}' behavior_pin_counter += 1 else: highest_net_number += 1 net = f'N{highest_net_number:02d}' unconnected_pins[hash_key] = net ports.append(net) sch_comp.ports = ports self.components[refdes] = sch_comp if refdes.startswith('X'): if sch_comp.attributes['type'].startswith("Ø"): have_embedded_subcircuit = True if not have_embedded_subcircuit: sub_circuit_name = value + os.path.extsep + 'qsch' mydir = self.circuit_file.parent.absolute().as_posix() sub_circuit_schematic_file = self._qsch_file_find(sub_circuit_name, mydir) if sub_circuit_schematic_file: sub_schematic = QschEditor(sub_circuit_schematic_file) sch_comp.attributes['_SUBCKT'] = sub_schematic # Store it for future use. else: _logger.warning(f"Subcircuit '{sub_circuit_name}' not found. Have you set the correct search paths?") for text_tag in self.schematic.get_items('text'): x, y = text_tag.get_attr(QSCH_TEXT_POS) # pyright: ignore[reportGeneralTypeIssues] point = Point(x, y) text: str = text_tag.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] text_size: int = text_tag.get_attr(QSCH_TEXT_SIZE) # pyright: ignore[reportAssignmentType] if text_tag.get_attr(QSCH_TEXT_COMMENT) == 1: # pyright: ignore[reportAssignmentType] type_text = TextTypeEnum.COMMENT elif text.startswith(QSCH_TEXT_INSTR_QUALIFIER): type_text = TextTypeEnum.DIRECTIVE text = text.lstrip(QSCH_TEXT_INSTR_QUALIFIER) # Eliminates the qualifer from the text. else: type_text = TextTypeEnum.NULL # angle = text_tag.get_attr(QSCH_TEXT_ROTATION) # TODO: Implement text Rotation text_obj = Text( point, text, text_size, type_text, # textAlignment, # verticalAlignment, # angle=angle, ) self.directives.append(text_obj) for line_tag in self.schematic.get_items('line'): line_tag: QschTag x1, y1 = line_tag.get_attr(QSCH_LINE_POS1) # pyright: ignore[reportGeneralTypeIssues] x2, y2 = line_tag.get_attr(QSCH_LINE_POS2) # pyright: ignore[reportGeneralTypeIssues] width: str = line_tag.get_attr(QSCH_LINE_WIDTH) # pyright: ignore[reportAssignmentType] line_type: str = line_tag.get_attr(QSCH_LINE_TYPE) # pyright: ignore[reportAssignmentType] color: str = line_tag.get_attr(QSCH_LINE_COLOR) # pyright: ignore[reportAssignmentType] line = Line(Point(x1, y1), Point(x2, y2)) line.style = LineStyle(width, line_type, color) self.lines.append(line) self.update_permission = UpdatePermission.Inform def _get_param_named(self, param_name): param_regex = re.compile(PARAM_REGEX(r"\w+"), re.IGNORECASE) param_name_upped = param_name.upper() text_tags = self.schematic.get_items('text') # pyright: ignore[reportOptionalMemberAccess] # Connection to directives and comments for tag in text_tags: if tag.get_attr(QSCH_TEXT_COMMENT) == 1: # if it is a comment, we ignore it continue line: str = tag.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] line = line.lstrip(QSCH_TEXT_INSTR_QUALIFIER) if line.upper().startswith('.PARAM'): for match in param_regex.finditer(line): if match.group("name").upper() == param_name_upped: return tag, match else: return None, None
[docs] def get_all_parameter_names(self) -> list[str]: # docstring inherited from BaseEditor param_names = [] param_regex = re.compile(PARAM_REGEX(r"\w+"), re.IGNORECASE) text_tags = self.schematic.get_items('text') # pyright: ignore[reportOptionalMemberAccess] # Connection to directives and comments for tag in text_tags: if tag.get_attr(QSCH_TEXT_COMMENT) == 1: # if it is a comment, we ignore it continue line: str = tag.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] line = line.lstrip(QSCH_TEXT_INSTR_QUALIFIER) if line.upper().startswith('.PARAM'): matches = param_regex.finditer(line) for match in matches: param_name = match.group('name') param_names.append(param_name.upper()) return sorted(param_names)
def _qsch_file_find(self, filename: str, work_dir: str | None = None) -> str | None: containers = ['.'] + self.custom_lib_paths + self.simulator_lib_paths # '.' is the directory where the script is located if (work_dir is not None) and work_dir != ".": containers = [work_dir] + containers # put work directory first return search_file_in_containers(filename, *containers)
[docs] def get_subcircuit(self, reference: str) -> 'QschEditor': """Returns an QschEditor file corresponding to the symbol""" subcircuit = self.get_component(reference) if '_SUBCKT' in subcircuit.attributes: # Optimization: if it was already stored, return it return subcircuit.attributes['_SUBCKT'] raise AttributeError(f"An associated subcircuit was not found for {reference}")
[docs] def get_subcircuit_named(self, name: str) -> 'BaseSubCircuit': raise NotImplementedError( f"QschEditor.get_subcircuit_named({name!r}) is not implemented; " "named subcircuit lookup is not currently supported for QSCH files." )
[docs] def get_parameter(self, param: str) -> str: # docstring inherited from BaseEditor tag, match = self._get_param_named(param) if match: return match.group('value') else: raise ParameterNotFoundError(param, f"QSCH file")
[docs] def set_parameter(self, param: str, value: ValueType) -> None: # docstring inherited from BaseEditor if isinstance(value, (int, float)): value_str: str = format_eng(value) elif isinstance(value, complex): value_str: str = format_eng(value.real) + "+" + format_eng(value.imag) + "j" else: value_str: str = value permission = self.begin_update() if permission == UpdatePermission.Deny: raise PermissionError(f"Permission denied for editing {param}") tag: QschTag tag, match = self._get_param_named(param) # pyright: ignore[reportAssignmentType] if match: _logger.debug(f"Parameter {param} found in QSCH file, updating it") text: str = tag.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] start, stop = match.span("value") start += len(QSCH_TEXT_INSTR_QUALIFIER) stop += len(QSCH_TEXT_INSTR_QUALIFIER) text = text[:start] + value_str + text[stop:] tag.set_attr(QSCH_TEXT_STR_ATTR, text) _logger.info(f"Parameter {param} updated to {value_str}") _logger.debug(f"Text at {tag.get_attr(QSCH_TEXT_POS)} Updated to {text}") if permission == UpdatePermission.Inform: self.end_update(param, value_str, UpdateType.UpdateParameter) else: # Was not found so we need to add it, _logger.debug(f"Parameter {param} not found in QSCH file, adding it") x, y = self._get_text_space() tag, _ = QschTag.parse( f'«text ({x},{y}) 1 0 0 0x1000000 -1 -1 "{QSCH_TEXT_INSTR_QUALIFIER}.param {param}={value_str}"»' ) self.schematic.items.append(tag) # pyright: ignore[reportOptionalMemberAccess] _logger.info(f"Parameter {param} added with value {value_str}") _logger.debug(f"Text added to {tag.get_attr(QSCH_TEXT_POS)} Added: {tag.get_attr(QSCH_TEXT_STR_ATTR)}") if permission == UpdatePermission.Inform: self.end_update(param, value_str, UpdateType.AddParameter)
# def _get_component_symbol(self, reference: str) -> tuple["BaseSchematic", str, QschTag]: # sub_circuit, ref = self._get_parent(reference) # if ref not in sub_circuit.components: # _logger.error(f"Component {ref} not found") # raise ComponentNotFoundError(f"Component {ref} not found in Schematic file") # # component = sub_circuit.components[ref] # symbol: QschTag = component.symbol_tag # return sub_circuit, ref, symbol
[docs] def set_component_value(self, reference: str, value: ValueType) -> None: # docstring inherited from BaseEditor if self.is_read_only(): raise ValueError("Editor is read-only") component = self.get_component(reference) component.set_value(value)
[docs] def set_element_model(self, device: str, model: str) -> None: # docstring inherited from BaseEditor component = self.get_component(device) component.set_value(model)
[docs] def get_component_value(self, reference: str) -> ValueType | None: # docstring inherited from BaseEditor component = self.get_component(reference) return component.get_value()
[docs] def get_component_parameters(self, reference: str) -> dict: """ Returns the parameters of the component in a dictionary. Since QSpice stores attributes by their order of appearance on the QSCH file, some parameters may not be found if they are not in the standard format. If a line contains a parameter definition that is on the standard format, it will be parsed and stored in the dictionary. The key of the dictionary is the line number where the parameter was found. :param reference: The reference of the component :return: A dictionary with the parameters of the component """ component = self.get_component(reference) return component.get_parameters()
[docs] def set_component_parameters(self, reference: str, **kwargs) -> None: """ Sets the parameters of the component. If key parameters that are integers, they represent the line number where the parameter was found. If the key is a string, it represents the parameter name. If the parameter name already exists, it will be replaced. If not found, it will be added as a new text line. """ component = self.get_component(reference) component.set_parameters(**kwargs)
[docs] def get_component_position(self, reference: str) -> tuple[Point, ERotation]: # docstring inherited from BaseSchematic component = self.get_component(reference) return component.position, component.rotation
[docs] def set_component_position(self, reference: str, position: Point | tuple, rotation: ERotation | int, mirror: bool = False, ) -> None: # docstring inherited from BaseSchematic component: QschComponent = self.get_component(reference) # pyright: ignore[reportAssignmentType] component.set_position(position, rotation, mirror)
[docs] def get_components(self, prefixes='*') -> list: # docstring inherited from BaseEditor if prefixes == '*': return list(self.components.keys()) return [k for k in self.components.keys() if k[0] in prefixes]
[docs] def remove_component(self, designator: str): # docstring inherited from BaseEditor permission = self.begin_update() if permission == UpdatePermission.Deny: raise PermissionError(f"Permission denied for removing component {designator}") component = self.get_component(designator) comp_tag: QschTag = component.attributes['tag'] self.schematic.items.remove(comp_tag) # pyright: ignore[reportOptionalMemberAccess] self.end_update(designator, None, UpdateType.DeleteComponent)
def _get_text_space(self): """ Returns the coordinate on the Schematic File canvas where a text can be appended. """ first = True for tag in self.schematic.items: # pyright: ignore[reportOptionalMemberAccess] if tag.tag in ('component', 'net', 'text'): x1, y1 = tag.get_attr(1) x2, y2 = x1, y1 # todo: the whole component primitives elif tag.tag == 'wire': x1, y1 = tag.get_attr(1) x2, y2 = tag.get_attr(2) else: continue # this avoids executing the code below when no coordinates are found if first: min_x = min(x1, x2) max_x = max(x1, x2) min_y = min(y1, y2) max_y = max(y1, y2) first = False else: min_x = min(min_x, x1, x2) max_x = max(max_x, x1, x2) min_y = min(min_y, y1, y2) max_y = max(max_y, y1, y2) if first: return 0, 0 # If no coordinates are found, we return the origin else: return min_x, min_y - 240 # Setting the text in the bottom left corner of the canvas
[docs] def add_instruction(self, instruction: str) -> None: # docstring inherited from BaseEditor permission = self.begin_update() if permission == UpdatePermission.Deny: raise PermissionError(f"Permission denied for adding instruction {instruction}") instruction = instruction.strip() # Clean any end of line terminators command = instruction.split()[0].upper() if command in UNIQUE_SIMULATION_DOT_INSTRUCTIONS: # Before adding new instruction, if it is a unique instruction, we just replace it for text_tag in self.schematic.get_items('text'): # pyright: ignore[reportOptionalMemberAccess] if text_tag.get_attr(QSCH_TEXT_COMMENT) == 1: # if it is a comment, we ignore it continue text: str = text_tag.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] text = text.lstrip(QSCH_TEXT_INSTR_QUALIFIER) command = text.split()[0].upper() if command in UNIQUE_SIMULATION_DOT_INSTRUCTIONS: text_tag.set_attr(QSCH_TEXT_STR_ATTR, QSCH_TEXT_INSTR_QUALIFIER + instruction) self.end_update("INSTRUCTION", text, UpdateType.DeleteInstruction) self.end_update("INSTRUCTION" ,instruction, UpdateType.AddInstruction) return # Job done, can exit this method elif command.startswith('.PARAM'): raise RuntimeError('The .PARAM instruction should be added using the "set_parameter" method') else: self.end_update("INSTRUCTION", instruction, UpdateType.AddInstruction) # If we get here, then the instruction was not found, so we need to add it x, y = self._get_text_space() tag, _ = QschTag.parse(f'«text ({x},{y}) 1 0 0 0x1000000 -1 -1 "{QSCH_TEXT_INSTR_QUALIFIER}{instruction}"»') self.schematic.items.append(tag) # pyright: ignore[reportOptionalMemberAccess]
[docs] def remove_instruction(self, instruction: str) -> bool: # docstring inherited from BaseEditor permission = self.begin_update() if permission == UpdatePermission.Deny: raise PermissionError(f"Permission denied for removing instruction {instruction}") for text_tag in self.schematic.get_items('text'): # pyright: ignore[reportOptionalMemberAccess] if text_tag.get_attr(QSCH_TEXT_COMMENT) == 1: # if it is a comment, we ignore it continue text: str = text_tag.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] if instruction in text: self.schematic.items.remove(text_tag) # pyright: ignore[reportOptionalMemberAccess] self.end_update("INSTRUCTION", instruction, UpdateType.DeleteInstruction) _logger.info(f'Instruction "{instruction}" removed') return True # Job done, can exit this method msg = f'Instruction "{instruction}" not found' _logger.error(msg) return False
[docs] def remove_Xinstruction(self, search_pattern: str) -> bool: # docstring inherited from BaseEditor permission = self.begin_update() if permission == UpdatePermission.Deny: raise PermissionError(f"Permission denied for removing instructions with pattern '{search_pattern}'") regex = re.compile(search_pattern, re.IGNORECASE) instr_removed = False for text_tag in self.schematic.get_items('text'): # pyright: ignore[reportOptionalMemberAccess] if text_tag.get_attr(QSCH_TEXT_COMMENT) == 1: # if it is a comment, we ignore it continue text: str = text_tag.get_attr(QSCH_TEXT_STR_ATTR) # pyright: ignore[reportAssignmentType] text = text.lstrip(QSCH_TEXT_INSTR_QUALIFIER) if regex.match(text): self.schematic.items.remove(text_tag) # pyright: ignore[reportOptionalMemberAccess] self.end_update("INSTRUCTION" ,text, UpdateType.DeleteInstruction) _logger.info(f'Instruction "{text}" removed') instr_removed = True if instr_removed: return True else: msg = f'Instruction matching "{search_pattern}" not found' _logger.error(msg) return False
[docs] def copy_from(self, editor: 'BaseSchematic') -> None: # docstring inherited from BaseSchematic super().copy_from(editor) # We need to copy the schematic information if isinstance(editor, QschEditor): from copy import deepcopy self.schematic = deepcopy(editor.schematic) else: # Need to create a new schematic from the netlist self.schematic = QschTag('schematic') for ref, comp in self.components.items(): cmpx = comp.position.X cmpy = comp.position.Y rotation = int(comp.rotation) // 45 comp_tag, _ = QschTag.parse(f'«component ({cmpx},{cmpy}) {rotation} 0»') if 'symbol' in comp.attributes: comp_tag.items.append(comp.attributes['symbol']) self.schematic.items.append(comp_tag) for labels in self.labels: label_tag, _ = QschTag.parse('«net (0,0) 1 13 0 "0"»') label_tag.set_attr(QSCH_NET_STR_ATTR, labels.text) label_tag.set_attr(QSCH_NET_POS, (labels.coord.X, labels.coord.Y)) self.schematic.items.append(label_tag) for wire in self.wires: wire_tag, _ = QschTag.parse('«wire (0,0) (0,0) "0"»') wire_tag.set_attr(QSCH_WIRE_POS1, (wire.V1.X, wire.V1.Y)) wire_tag.set_attr(QSCH_WIRE_POS2, (wire.V2.X, wire.V2.Y)) # wire_tag.set_attr(QSCH_WIRE_NET, wire.net) self.schematic.items.append(wire_tag) for line in self.lines: line_tag, _ = QschTag.parse('«line (2000,1300) (3150,-100) 0 2 0xff0000 -1 -1»') line_tag.set_attr(QSCH_LINE_POS1, (line.V1.X, line.V1.Y)) line_tag.set_attr(QSCH_LINE_POS2, (line.V2.X, line.V2.Y)) # TODO: Implement the style to width, type and color # line_width = 0 # Default # line_type = 0 # Default # color = 0xff0000 # Default : Blue Color # line_width, line_type, color = line.style.split(' ') # line_tag.set_attr(QSCH_LINE_WIDTH, line_width) # line_tag.set_attr(QSCH_LINE_TYPE, line_type) # line_tag.set_attr(QSCH_LINE_COLOR, color) self.schematic.items.append(line_tag) for shape in self.shapes: # TODO: Implement the line type and width conversion from LTSpice to QSpice. shape_tag = None if shape.name == "RECTANGLE" or shape.name == "rect": shape_tag, _ = QschTag.parse('«rect (1850,1550) (3650,-400) 0 0 2 0xff0000 0x1000000 -1 0 -1»') shape_tag.set_attr(QSCH_RECT_POS1, (shape.points[0].X, shape.points[0].Y)) shape_tag.set_attr(QSCH_RECT_POS2, (shape.points[1].X, shape.points[1].Y)) # shape_tag.set_attr(QSCH_RECT_LINE_TYPE, shape.line_style.pattern) # shape_tag.set_attr(QSCH_RECT_LINE_WIDTH, shape.line_style.width) # shape_tag.set_attr(QSCH_RECT_LINE_COLOR, shape.line_style.color) elif shape.name == "CIRCLE" or shape.name == "ellipse": shape_tag, _ = QschTag.parse('«ellipse (2100,1150) (2650,150) 0 0 2 0xff0000 0x1000000 -1 -1»') shape_tag.set_attr(QSCH_ELLIPSE_POS1, (shape.points[0].X, shape.points[0].Y)) shape_tag.set_attr(QSCH_ELLIPSE_POS2, (shape.points[1].X, shape.points[1].Y)) # shape_tag.set_attr(QSCH_ELLIPSE_LINE_COLOR, shape.line_style.color) # shape_tag.set_attr(QSCH_ELLIPSE_FILL_COLOR, shape.fill) elif shape.name == "ARC" or shape.name == "arc3p": shape_tag, _ = QschTag.parse('«arc3p (2700,300) (2250,1200) (2500,800) 0 2 0xff0000 -1 -1»') # TODO: Implement the ARC shape correctly. # In LTSpice the First two points defines the bounding box of the arc and the following # two points define the start and end of the arc. # QSpice uses the first two points to define the start and end of the arc and the third point # to define the curvature of the arc. if shape.name == "ARC": assert len(shape.points) == 4, "Invalid LTSpice shape" center_x = int((shape.points[0].X + shape.points[1].X) / 2) center_y = int((shape.points[0].Y + shape.points[1].Y) / 2) start_angle = math.atan2(shape.points[2].Y - center_y, shape.points[2].X - center_x) end_angle = math.atan2(shape.points[3].Y - center_y, shape.points[3].X - center_x) ellipse_width = abs(shape.points[1].X - shape.points[0].X) ellipse_height = abs(shape.points[1].Y - shape.points[0].Y) start_point_x = int(center_x + ellipse_width / 2 * math.cos(start_angle)) start_point_y = int(center_y + ellipse_height / 2 * math.sin(start_angle)) end_point_x = int(center_x + ellipse_width / 2 * math.cos(end_angle)) end_point_y = int(center_y + ellipse_height / 2 * math.sin(end_angle)) shape_tag.set_attr(QSCH_ARC3P_POS1, (start_point_x, start_point_y)) shape_tag.set_attr(QSCH_ARC3P_POS2, (end_point_x, end_point_y)) shape_tag.set_attr(QSCH_ARC3P_POS3, (center_x, center_y)) else: shape_tag.set_attr(QSCH_ARC3P_POS1, (shape.points[0].X, shape.points[0].Y)) shape_tag.set_attr(QSCH_ARC3P_POS2, (shape.points[1].X, shape.points[1].Y)) shape_tag.set_attr(QSCH_ARC3P_POS3, (shape.points[2].X, shape.points[2].Y)) # shape_tag.set_attr(QSCH_ARC3P_LINE_COLOR, shape.line_style.color) else: print(f"Invalid shape {shape.name}. Being ignored. Ask Developper to implement this") if shape_tag: self.schematic.items.append(shape_tag) for text in self.directives: text_tag, _ = QschTag.parse('«text (0,0) 1 7 0 0x1000000 -1 -1 "text"»') text_tag.set_attr(QSCH_TEXT_STR_ATTR, QSCH_TEXT_INSTR_QUALIFIER + text.text) text_tag.set_attr(QSCH_TEXT_POS, (text.coord.X, text.coord.Y)) self.schematic.items.append(text_tag)