"""Generate circuit models through netlists"""
from enum import Enum
from typing import Any, List, Dict, Set, Tuple, Union
from jinja2 import Environment, PackageLoader, select_autoescape
import itertools
import logging
logger = logging.getLogger(__name__)
_template_env = Environment(
loader=PackageLoader("cbadc", package_path="circuit/templates"),
autoescape=select_autoescape(),
trim_blocks=True,
lstrip_blocks=True,
)
[docs]class SpiceDialect(Enum):
ngspice = "ngspice"
specter = "spectre"
[docs]class ComponentType(Enum):
resistor = "R"
capacitor = "C"
inductor = "L"
voltage_source = ("V",)
voltage_controlled_voltage_source = "E"
current_controlled_voltage_source = "H"
voltage_controlled_current_source = "F"
current_controlled_current_source = "G"
current_source = "I"
switch = "S"
diode = "D"
mosfet = "M"
bipolar = "Q"
sub_circuit = "X"
xspice = "A"
[docs]class Terminal:
"""A terminal is a connection point for a component
Parameters
----------
name : str, optional
The name of the terminal
"""
name: str
id_iter = itertools.count(1)
hidden: bool
def __init__(self, name="", hidden=False):
self.id = next(self.id_iter)
if not name:
name = f"{self.id}"
self.name = name.upper()
self.hidden = hidden
def __str__(self):
return self.name
[docs]class Ground(Terminal):
"""The ground terminal"""
def __init__(self):
self.name = "0"
self.id = 0
self.hidden = False
[docs]class Port:
"""A port is a collection of terminals that are connected together
Parameters
----------
terminals : Tuple[Terminal, Terminal]
The terminals that are connected together
"""
terminals: Set[Terminal]
id_iter = itertools.count(1)
name: str
def __init__(self, terminals: Tuple[Terminal, Terminal]):
self.id = next(self.id_iter)
self.terminals = {*terminals}
[docs] def add_terminal(self, terminal: Terminal):
"""Add a terminal to the port
Parameters
----------
terminal : Terminal
The terminal to add
"""
self.terminals.add(terminal)
[docs] def merge(self, other):
"""Merge another port into this port
Parameters
----------
other : Port
The port to merge into this port
"""
if not isinstance(other, Port):
raise TypeError("Can only merge Port objects")
self.terminals = self.terminals.union(other.terminals)
[docs]class DeviceModel:
"""A device model
Parameters
----------
name : str
The name of the model
type_name : str
The type name of the model (used for later instantiation)
comments : List[str], optional
Comments to add to the model, by default []
**kwargs : Dict[str, str]
The parameters of the model
"""
ng_spice_model_name: str
model_name: str
parameters: Dict[str, str]
comments: List[str]
verilog_ams: bool
def __init__(
self,
model_name: str,
comments=[],
**kwargs,
) -> None:
self.model_name = model_name.lower()
self.parameters = kwargs
self.comments = comments
self.verilog_ams = False
def get_ngspice(self):
raise NotImplementedError
def get_spectre(self):
raise NotImplementedError
def get_verilog_ams(self):
raise NotImplementedError
def __eq__(self, __o: object) -> bool:
if not isinstance(__o, DeviceModel):
return False
return (self.ng_spice_model_name, self.model_name) == (
__o.ng_spice_model_name,
__o.model_name,
)
def __hash__(self) -> int:
return hash((self.ng_spice_model_name, self.model_name))
[docs]class CircuitElement:
"""Circuit element base class
Parameters
----------
component_type : ComponentType
The type of the component
terminals : List[Terminal]
The terminals of the component
instance_name : str, optional
The instance name of the component, by default ''
comments : List[str], optional
Comments to add to the component, by default []
model : DeviceModel, optional
The model to use for the component, by default None
library : str, optional
The library to use for the component, by default ''
include : str, optional
The include file to use for the component, by default ''
"""
instance_name: str
_terminals: List[Terminal]
_terminal_lookup: Dict[str, int]
_parameters_dict: Dict[str, str]
_parameter_list: List[str]
comments: List[str]
model: DeviceModel
def __init__(
self,
instance_name: str,
terminals: List[Terminal],
*args,
comments: List[str] = [],
model: DeviceModel = None,
**kwargs,
):
# Make sure instance name is of correct type and format
if not instance_name or not isinstance(instance_name, str):
raise ValueError("Instance name must be a non-empty string")
self.instance_name = instance_name[0] + instance_name[1:].lower()
# Add terminals
self._terminals = []
self._terminal_lookup = {}
self.add_terminals(terminals)
# Make sure comments are of correct type
if not isinstance(comments, list):
raise ValueError("Comments must be a list")
for comment in comments:
if not isinstance(comment, str):
raise ValueError("Comments must be of type str")
self.comments = comments
# Make sure model is of correct type
if model and not isinstance(model, DeviceModel):
raise ValueError("Model must be of type DeviceModel")
self.model = model
self._parameter_list = [str(arg) for arg in args]
self._parameters_dict = {key: str(value) for key, value in kwargs.items()}
def _get_terminal_names(self, connections: Dict[Terminal, Port]) -> List[str]:
terminal_names = []
for terminal in self._terminals:
if terminal in connections:
if connections[terminal].name:
terminal_names.append(connections[terminal].name)
else:
terminal_names.append(f"{connections[terminal].id}")
else:
if not terminal.hidden:
logger.warning(f"Terminal {terminal} is not connected to anything")
terminal_names.append(str(terminal))
return terminal_names
[docs] def get_ngspice(self, connections: Dict[Terminal, Port]):
"""Get the ngspice call for the component"""
raise NotImplementedError
[docs] def get_spectre(self, connections: Dict[Terminal, Port]):
"""Get the spectre call for the component"""
raise NotImplementedError
def _get_model_set(self, verilog_ams=False) -> List[DeviceModel]:
if self.model:
return [self.model]
return []
[docs] def get_terminals(self) -> List[Terminal]:
"""Get the terminals of the component"""
return self._terminals
[docs] def add_terminal(self, terminal: Terminal, index: int = -1):
"""Add a terminal to the component
Parameters
----------
terminal : Terminal
The terminal to add
index : int, optional
The index to add the terminal at, by default -1, i.e., at the end
"""
if not isinstance(terminal, Terminal):
raise ValueError("Terminal must be of type Terminal")
if index == -1:
if terminal.name in self._terminal_lookup:
raise ValueError(f"Terminal {terminal.name} name already exists")
self._terminal_lookup[terminal.name] = len(self._terminals)
self._terminals.append(terminal)
elif index >= 0 and index < len(self._terminals):
self._terminals.insert(index, terminal)
# Update terminal lookup
for index, term in enumerate(self._terminals):
self._terminal_lookup[term.name] = index
else:
raise ValueError("Index out of range")
def __getitem__(self, key) -> Terminal:
if isinstance(key, int):
return self._terminals[key]
elif isinstance(key, str):
upper_key = key.upper()
if upper_key not in self._terminal_lookup:
raise ValueError(f"Terminal {upper_key} does not exist")
return self._terminals[self._terminal_lookup[upper_key]]
raise ValueError("Key must be of type int or str")
[docs] def add_terminals(self, terminals: List[Terminal]):
"""Add multiple terminals to the component
Parameters
----------
terminals : List[Terminal]
The terminals to add
"""
if not isinstance(terminals, list):
raise ValueError("Terminals must be a list")
for terminal in terminals:
if not isinstance(terminal, Terminal):
raise ValueError("Terminals must be of type Terminal")
self.add_terminal(terminal)
def __str__(self):
result = [
"Circuit Element\n---------------",
f"Name: {self.instance_name}",
f"Terminals: {[str(x) for x in self._terminals]}",
f"Parameters: {self._parameters_dict}",
f"Comments: {self.comments}",
"---------------",
]
return "\n".join(result)
[docs]class SubCircuitElement(CircuitElement):
"""Subcircuit element class
Parameters
----------
terminals : List[Terminal]
The terminals of the component
subckt_name : str
The name of the subcircuit
instance_name : str, optional
The instance name of the component, by default ''
comments : List[str], optional
Comments to add to the component, by default []
"""
subckt_name: str
_internal_connections: Dict[Terminal, Port]
_subckts: Dict[str, CircuitElement]
_subckt_names: List[str] = []
def __init__(
self,
instance_name: str,
subckt_name: str,
terminals: List[Terminal],
*args,
comments: List[str] = [],
**kwargs,
):
if not instance_name or not isinstance(instance_name, str):
raise ValueError("Instance name must be a non-empty string")
# Make sure the instance name is valid
if instance_name[0] != "X":
instance_name = f"X{instance_name}"
super().__init__(
instance_name,
terminals,
subckt_name,
*args,
comments=comments,
**kwargs,
)
if not subckt_name or not isinstance(subckt_name, str):
raise ValueError("Subcircuit name must be a non-empty string")
self.subckt_name = subckt_name.lower()
self._subckts = {}
self._internal_connections = {Ground(): Port((Ground(), Ground()))}
[docs] def get_ngspice(self, connections: Union[Dict[Terminal, Port], None] = None):
connections = self._merge_connections_downstream(connections)
return _template_env.get_template("ngspice/sub_circuit.cir.j2").render(
{
"instance_name": self.instance_name,
"subckt_name": self.subckt_name,
"terminals": self._get_terminal_names(connections),
"parameters": self._parameters_dict,
}
)
def _merge_connections_downstream(
self, connections: Union[Dict[Terminal, Port], None]
):
if connections is None:
connections = self._internal_connections
if not isinstance(connections, dict):
raise ValueError("Connections must be a Dict[Terminal, Port]")
for key, value in connections.items():
if not isinstance(key, Terminal):
raise ValueError("Connections must be a Dict[Terminal, Port]")
if not isinstance(value, Port):
raise ValueError("Connections must be a Dict[Terminal, Port]")
return connections
[docs] def get_spectre(self, connections: Union[Dict[Terminal, Port], None] = None):
connections = self._merge_connections_downstream(connections)
return _template_env.get_template("spectre/sub_circuit.cir.j2").render(
{
"instance_name": self.instance_name,
"subckt_name": self.subckt_name,
"terminals": self._get_terminal_names(connections),
"parameters": self._parameters_dict,
}
)
[docs] def get_sub_circuit_definitions(
self, dialect: SpiceDialect = SpiceDialect.ngspice
) -> List[str]:
"""Get the spice definition of the subcircuit"""
if dialect == SpiceDialect.ngspice:
result = [
_template_env.get_template(
"ngspice/sub_circuit_definition.cir.j2"
).render(
{
"subckt_name": self.subckt_name,
"terminals": self._get_terminal_names(
self._internal_connections
),
"parameters": self._parameters_dict,
"sub_circuits": self._get_subckts(),
"connections": self._internal_connections,
}
)
]
for sub_circuit in self._get_subckts():
if isinstance(sub_circuit, SubCircuitElement):
for sub_sub_circuit in sub_circuit.get_sub_circuit_definitions():
if sub_sub_circuit not in result:
result.append(sub_sub_circuit)
return result
elif dialect == SpiceDialect.specter:
return self._get_spectre_sub_circuit_definition()
raise ValueError(f"Unknown dialect {dialect}")
def _get_spectre_sub_circuit_definition(self) -> List[str]:
result = [
_template_env.get_template("spectre/sub_circuit_definition.cir.j2").render(
{
"subckt_name": self.subckt_name,
"terminals": self._get_terminal_names(self._internal_connections),
"parameters": self._parameters_dict,
"sub_circuits": self._get_subckts(),
"connections": self._internal_connections,
}
)
]
for sub_circuit in self.subckt_components:
if isinstance(sub_circuit, SubCircuitElement):
result.extend(sub_circuit._get_spectre_sub_circuit_definition())
return result
def _get_model_set(self, verilog_ams=False) -> List[DeviceModel]:
result_set = []
for sub_circuit in self._get_subckts():
candidates = sub_circuit._get_model_set(verilog_ams)
for candidate in candidates:
if candidate not in result_set:
result_set.append(candidate)
return result_set
def _get_subckts(self):
return self._subckts.values()
def __setattr__(self, __name: Any, __value: Any) -> None:
if not isinstance(__name, str):
raise ValueError("Subcircuit instance name must be a string")
if isinstance(__value, CircuitElement):
# Check if a new subcircuit is being added
if __name not in self._subckts:
if __name != __value.instance_name:
raise ValueError(
f"Instance name of {__value.instance_name} is not {__name}"
)
else:
# Replace the existing subcircuit
if __name != __value.instance_name:
__value.instance_name = __name
old_subckt = self._subckts[__name]
for terminal in old_subckt.get_terminals():
self.connect(__value[terminal.name], terminal)
self._subckts[__name] = __value
return None
return super().__setattr__(__name, __value)
def __getattr__(self, __name: str) -> Any:
if __name in self._subckts:
return self._subckts[__name]
raise AttributeError(f"Subcircuit {self.subckt_name} has no attribute {__name}")
# def __getitem__(self, __name: Union[str, int]) -> Union[CircuitElement, Terminal]:
# if isinstance(__name, int) or __name in self._terminals:
# return super().__getitem__(__name)
# elif __name in self._subckts:
# return self._subckts[__name]
# raise KeyError(f"Subcircuit {self.subckt_name} has no element {__name}")
# def __setitem__(self, __name: str, __value: Any) -> None:
# if isinstance(__value, CircuitElement):
# if __name is not __value.instance_name:
# raise ValueError(f"Instance name of {__value} is not {__name}")
# self._subckts[__name] = __value
# return None
# raise ValueError(
# f"Subcircuit {self.subckt_name} can only contain CircuitElements"
# )
[docs] def add(self, *elements: CircuitElement):
"""Add elements to the subcircuit
Parameters
----------
elements : CircuitElement
The elements to add
"""
for element in elements:
if not isinstance(element, CircuitElement):
raise ValueError(f"Element {element} is not a CircuitElement")
self.__setattr__(element.instance_name, element)
[docs] def connects(self, *connections: Tuple[Terminal, Terminal]):
"""Add connections to the subcircuit
Parameters
----------
args : list[tuple[Terminal, Terminal]]
The connections to add
"""
for conn in connections:
self.connect(conn[0], conn[1])
[docs] def connect(self, first: Terminal, second: Terminal, name=""):
"""Add a connection between two terminals
Parameters
----------
first : Terminal
First terminal
second : Terminal
Second terminal
name : str, optional
Name of the connection, by default tries to inherit the name of the first named terminal
Notes
-----
- If the terminals are already connected, the connection is not changed
- If one of the terminals is already connected, the other terminal is added to the connection
- If neither of the terminals is connected, a new connection is created
- If both terminals are connected to different connections, the connections are merged
- If both terminals are connected to the same connection, the connection is not changed
- If one of the terminals has a name, the connection is named after the terminal with the name
- If both terminals have a name, the connection is named after the first terminal
- If neither of the terminals have a name, the connection is unnamed
"""
if not isinstance(first, Terminal):
raise ValueError(f"First terminal {first} is not a Terminal")
if not isinstance(second, Terminal):
raise ValueError(f"Second terminal {second} is not a Terminal")
if first in self._internal_connections and second in self._internal_connections:
self._internal_connections[first].merge(self._internal_connections[second])
self._internal_connections[second] = self._internal_connections[first]
elif (
first in self._internal_connections
and second not in self._internal_connections
):
self._internal_connections[first].add_terminal(second)
self._internal_connections[second] = self._internal_connections[first]
elif (
first not in self._internal_connections
and second in self._internal_connections
):
self._internal_connections[second].add_terminal(first)
self._internal_connections[first] = self._internal_connections[second]
else:
self._internal_connections[first] = Port((first, second))
self._internal_connections[second] = self._internal_connections[first]
if name:
self._internal_connections[first].name = name
else:
if first.name:
self._internal_connections[first].name = first.name
elif second.name:
self._internal_connections[first].name = second.name
else:
self._internal_connections[first].name = ""
[docs] def check_connections(self):
"""Check that all terminals are connected
Raises
------
ValueError
If a terminal of the subckt is not connected
"""
for component in self._get_subckts():
if isinstance(component, SubCircuitElement):
component.check_connections()
for terminal in component.get_terminals():
if terminal not in self._internal_connections and not terminal.hidden:
logger.warning(
f"Terminal {terminal} of component {component} not connected and not marked as hidden"
)
# raise ValueError(
# f"Terminal {terminal} of component {component} not connected"
# )
for terminal in self.get_terminals():
if terminal not in self._internal_connections and not terminal.hidden:
logger.warning(
f"Terminal {terminal} is not connected and not marked as hidden"
)
# raise ValueError(
# f"Terminal {terminal} named {self.instance_name} not connected"
# )
def _check_subckt_names(self) -> List[str]:
# Lazy evaluation of subckt names
if not self._subckt_names:
self._subckt_names = [self.subckt_name]
for component in self._get_subckts():
if isinstance(component, SubCircuitElement):
self._subckt_names.extend(component._check_subckt_names())
return self._subckt_names
[docs] def check_subckt_names(self):
"""Check that all subcircuit names are unique
Raises
------
ValueError
If a subcircuit name is not unique
"""
for component in self._get_subckts():
if isinstance(component, SubCircuitElement):
component.check_subckt_names()
if self.subckt_name in component._check_subckt_names():
raise ValueError(f"Subcircuit name {self.subckt_name} not unique")
[docs] def connect_upstream(self):
"""Connect all terminals of the subcircuit to the parent circuit
Notes
-----
- If a terminal is already connected, the connection is not changed
- If a terminal is not connected, a new connection is created
- If a terminal has a name, the connection is named after the terminal
"""
for component in self._get_subckts():
if isinstance(component, SubCircuitElement):
component.connect_upstream()
for terminal in component.get_terminals():
if terminal not in self._internal_connections:
self._terminals.append(Terminal())
self.connect(terminal, self.terminals[-1])
def __str__(self):
return """
This Circuit Element is a SubCircuitElement with the following subcircuit components:
--------------------
""" + "\n\n\n".join(
[str(x) for x in self.subckt_components]
)
[docs]class NetlistElement(SubCircuitElement):
"""A SpiceNetlist is a SubCircuitElement that can be used to include a netlist in a circuit
Parameters
----------
netlist : str
The netlist to include
"""
_id_iter = itertools.count(1)
def __init__(self, netlist: str):
# check if netlist is a filename
if netlist.endswith((".txt", ".net", ".cir")):
# if so read from the file
with open(netlist, "r") as f:
netlist = f.read()
# Parse netlist
netlist_lines = netlist.splitlines()
first_line = netlist_lines[0].split()
# Check for .subckt statement
if not first_line[0].lower() == ".subckt":
raise ValueError(
f"Netlist does not start with .subckt, but with {first_line[0]}"
)
# Check for .endc statement
_endc_statement = False
for line in netlist_lines[1:]:
if line.split()[0].lower() == ".ends":
_endc_statement = True
if not _endc_statement:
raise ValueError("Netlist does have a required .ends statement")
# Extract subcircuit and terminal names.
subckt_name = first_line[1].lower()
terminals = [Terminal(term_name.upper()) for term_name in first_line[2:]]
self._netlist = netlist
super().__init__(
f"X{next(self._id_iter)}",
subckt_name,
terminals,
)
[docs] def get_sub_circuit_definitions(
self, dialect: SpiceDialect = SpiceDialect.ngspice
) -> List[str]:
"""Get the spice definition of the subcircuit"""
return [self._netlist]
def __str__(self):
return f"""
This Circuit Element is a NetlistElement with the following definition:
--------------------
{self._netlist}
"""
# SPICE_VALUE = Union[float, str, int]
SPICE_VALUE = float
from . import (
analog_frontend,
digital_control,
opamp,
ota,
simulator,
testbench,
state_space,
components,
models,
)