# -------------------------------------------------------------------------------
#
# ███████╗██████╗ ██╗ ██████╗███████╗██╗ ██╗██████╗
# ██╔════╝██╔══██╗██║██╔════╝██╔════╝██║ ██║██╔══██╗
# ███████╗██████╔╝██║██║ █████╗ ██║ ██║██████╔╝
# ╚════██║██╔═══╝ ██║██║ ██╔══╝ ██║ ██║██╔══██╗
# ███████║██║ ██║╚██████╗███████╗███████╗██║██████╔╝
# ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚══════╝╚═╝╚═════╝
#
# 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)