diff --git a/.flake8 b/.flake8 index d69c23e..0d997dc 100644 --- a/.flake8 +++ b/.flake8 @@ -9,3 +9,5 @@ exclude = build, dist, node_modules + +per-file-ignores = __init__.py:F401 diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml index 9dab06b..b31353c 100644 --- a/.github/workflows/test-deploy.yml +++ b/.github/workflows/test-deploy.yml @@ -22,7 +22,10 @@ jobs: uses: s-weigand/setup-conda@v1 - name: Create Test Environment - run: conda env create --file environment.yml + run: | + conda env create --file environment.yml + cd python + pip install . - name: Install GraphGym run: | diff --git a/bin/parse-pyspice b/bin/parse-pyspice index 9ba103d..111726c 100755 --- a/bin/parse-pyspice +++ b/bin/parse-pyspice @@ -1,214 +1,11 @@ #! /usr/bin/env python3 import argparse -import importlib -import inspect import json import os from pathlib import Path -from typing import Any, Union +from typing import Union -SPLIT_PLACE_HOLDER = "~" - - -def _get_missing_attribute_meta(param: str, class_name: str) -> dict: - if param == "dc_offset": - return { - "name": "dc_offset", - "parameter": "dc offset", - "default_value": 0, - "units": "A" if "Current" in class_name else "V", - } - elif param == "ac_magnitude": - return { - "name": "ac_magnitude", - "parameter": "ac magnitude", - "default_value": 0, - "units": "A" if "Current" in class_name else "V", - } - elif param == "values": - return { - "name": "values", - "parameter": "Piecewise Linear Values", - "default_value": "[(0, 0)]", - "units": "A" if "Current" in class_name else "V", - } - elif param == "repeate_time": - return { - "name": "repeate_time", - "parameter": "Repeat Time", - "default_value": 0, - "units": "sec", - } - elif param == "delay_time" or param == "time_delay": - return { - "name": param, - "parameter": "Delay Time", - "default_value": 0, - "units": "sec", - } - elif param == "duration": - return { - "name": "duration", - "parameter": "Duration for random values to show up", - "default_value": 0, - "units": "sec", - } - else: - return {} - - -def _get_attribute_constraints(name: str) -> Union[dict, None]: - if name == "random_type": - return {"possible_values": ["uniform", "exponential", "gaussian", "poisson"]} - - -class PySpiceMissingException(Exception): - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - - -class PySpiceParser: - """A class to parse PySpice python library - - This class parses the PySpice python library - for attributes of the various components to be used in SPICE simulations - """ - - def __init__(self) -> None: - self._high_level_elements = [] - self._basic_elements = [] - self._confirm_pyspice_existence() - self._meta_dict = {} - - def run(self) -> dict: - import PySpice - - self._parse_basic_elements() - print(f"Found {len(self._basic_elements)} basic elements") - - self._parse_highlevel_elements() - print(f"Found {len(self._high_level_elements)} high level elements") - - return { - "high_level_elements": self._high_level_elements, - "basic_elements": self._basic_elements, - "pyspice_version": str(PySpice.__version__), - } - - def _parse_basic_elements(self) -> None: - pass - - def _parse_highlevel_elements(self) -> None: - from PySpice.Spice import HighLevelElement, _get_elements - - for element in _get_elements(HighLevelElement): - if "Spice.HighLevelElement" in repr(element): - self._high_level_elements.append( - self._parse_high_level_members(element) - ) - - def _parse_high_level_members(self, class_: type) -> dict: - class_details = { - "name": class_.__name__, - "pins_count": 2, - "attributes": [], - "class_doc": class_.__doc__, - } - for super_class in inspect.getmro(class_): - if "Mixin" in repr(super_class) and "MixinAbc" not in repr(super_class): - mixin_signature = inspect.signature(super_class) - class_details["mixin_doc"] = super_class.__doc__ - meta_dict = self._add_to_meta_dict(class_.__name__, super_class.__doc__) - for param_name, param in mixin_signature.parameters.items(): - if not (val := meta_dict.get(param_name)): - val = _get_missing_attribute_meta(param_name, class_.__name__) - - class_details["attributes"].append( - { - "name": param_name, - "default": self._get_default_for(param_name, param), - "constraints": _get_attribute_constraints(param_name), - "attribute_meta": val if val is not None else {}, - } - ) - - return class_details - - def _add_to_meta_dict(self, class_name: str, class_doc: str) -> dict: - meta_dict = {} - if "TRRANDOM" in class_doc: - return {} - usable_lines = [] - for line in class_doc.split("\n"): - if line.strip().startswith("+") or line.strip().startswith("|"): - if line and "-" not in line: - usable_lines.append( - line.replace("Td1+Tstep", "sum(Td1, Tstep)") - .strip() - .replace("|", "") - .strip() - .replace("+", SPLIT_PLACE_HOLDER) - ) - - if len(usable_lines): - meta_keys = list( - key.strip() for key in usable_lines[0].split(SPLIT_PLACE_HOLDER) - ) - for params in usable_lines[1:]: - params_gen = (val.strip() for val in params.split(SPLIT_PLACE_HOLDER)) - params_dict = {} - param_name = "" - for j, val in enumerate(params_gen): - meta_key = self._to_camel_case(meta_keys[j]) - if meta_key == "parameter": - param_name = self._to_camel_case(val) - - if (freq := "1/sec") in val: - val = val.replace(freq, "Hz") - - if "sum" in val or "," not in val: - params_dict[meta_key] = val - elif "Current" in class_name: - params_dict[self._to_camel_case(meta_keys[j])] = "A" - elif "Voltage" in class_name: - params_dict[self._to_camel_case(meta_keys[j])] = "V" - - meta_dict[param_name] = params_dict - - return meta_dict - - @staticmethod - def _to_camel_case(string: str) -> str: - return "_".join(string.lower().split(" ")).replace(".", "") - - @staticmethod - def _confirm_pyspice_existence() -> None: - try: - pyspice = importlib.import_module("PySpice") - except ImportError as e: - raise PySpiceMissingException(str(e)) - del pyspice - - @staticmethod - def _get_default_for(param_name: str, parameter: inspect.Parameter) -> Any: - if param_name == "values": - return "[(0, 0)]" - if param_name == "random_type": - return "uniform" - if param_name == "damping_factor": - return 0.01 - if param_name in ( - "phase", - "fall_delay_time", - "fall_time_constant", - "rise_time_constant", - ): - return 0 - return ( - parameter.default - if not parameter.default == inspect.Parameter.empty - else 0.0 - ) +from symbench.electric_circuits.parser import PySpiceParser class PySpiceSaver: diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..c452753 --- /dev/null +++ b/python/README.md @@ -0,0 +1,2 @@ +# electric-circuits +This is a standalone Python package intended to be used with webGME `electric-circuits`, offering common functionality to requiring Python in the repository. diff --git a/python/environment.yml b/python/environment.yml new file mode 120000 index 0000000..7a9c790 --- /dev/null +++ b/python/environment.yml @@ -0,0 +1 @@ +../environment.yml \ No newline at end of file diff --git a/python/setup.py b/python/setup.py new file mode 100644 index 0000000..34bf983 --- /dev/null +++ b/python/setup.py @@ -0,0 +1,36 @@ +""" +electric-circuits +WebGME-Spice Facilitation/Utility Library +""" +import sys + +from setuptools import find_namespace_packages, setup + +short_description = __doc__.split("\n") + +# from https://github.com/pytest-dev/pytest-runner#conditional-requirement +needs_pytest = {"pytest", "test", "ptr"}.intersection(sys.argv) +pytest_runner = ["pytest-runner"] if needs_pytest else [] + + +setup( + # Self-descriptive entries which should always be present + name="electric-circuits", + author="Symbench", + author_email="umesh.timalsina@vanderbilt.edu", + description=short_description[0], + long_description="\n".join(short_description), + long_description_content_type="text/markdown", + version="0.1.0", + license="apache-2.0", + # Which Python importable modules should be included when your package is installed + # Handled automatically by setuptools. Use 'exclude' to prevent some specific + # subpackage(s) from being added, if needed + packages=find_namespace_packages(include=["symbench.*"]), + # Optional include package data to ship with your package + # Customize MANIFEST.in if the general case does not suit your needs + # Comment out this line to prevent the files from being packaged with your software + include_package_data=True, + # Allows `setup.py test` to work correctly with pytest + setup_requires=[] + pytest_runner, +) diff --git a/python/symbench/electric_circuits/__init__.py b/python/symbench/electric_circuits/__init__.py new file mode 100644 index 0000000..fc68c83 --- /dev/null +++ b/python/symbench/electric_circuits/__init__.py @@ -0,0 +1,2 @@ +from .parser import PySpiceParser +from .plugin_bases import AnalyzeCircuit, CircuitToPySpiceBase diff --git a/python/symbench/electric_circuits/exceptions.py b/python/symbench/electric_circuits/exceptions.py new file mode 100644 index 0000000..a602a47 --- /dev/null +++ b/python/symbench/electric_circuits/exceptions.py @@ -0,0 +1,12 @@ +from typing import Any + + +class PySpiceMissingException(Exception): + """Error to be raised when PySpice is missing""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + +class PySpiceConversionError(Exception): + """Error to be raised when there's an error in PySpice conversion""" diff --git a/python/symbench/electric_circuits/parser.py b/python/symbench/electric_circuits/parser.py new file mode 100644 index 0000000..044df82 --- /dev/null +++ b/python/symbench/electric_circuits/parser.py @@ -0,0 +1,204 @@ +import inspect +from typing import Any, Union + +from symbench.electric_circuits.exceptions import PySpiceMissingException + +SPLIT_PLACE_HOLDER = "~" + +__all__ = "PySpiceParser" + + +def _get_missing_attribute_meta(param: str, class_name: str) -> dict: + if param == "dc_offset": + return { + "name": "dc_offset", + "parameter": "dc offset", + "default_value": 0, + "units": "A" if "Current" in class_name else "V", + } + elif param == "ac_magnitude": + return { + "name": "ac_magnitude", + "parameter": "ac magnitude", + "default_value": 0, + "units": "A" if "Current" in class_name else "V", + } + elif param == "values": + return { + "name": "values", + "parameter": "Piecewise Linear Values", + "default_value": "[(0, 0)]", + "units": "A" if "Current" in class_name else "V", + } + elif param == "repeate_time": + return { + "name": "repeate_time", + "parameter": "Repeat Time", + "default_value": 0, + "units": "sec", + } + elif param == "delay_time" or param == "time_delay": + return { + "name": param, + "parameter": "Delay Time", + "default_value": 0, + "units": "sec", + } + elif param == "duration": + return { + "name": "duration", + "parameter": "Duration for random values to show up", + "default_value": 0, + "units": "sec", + } + else: + return {} + + +def _get_attribute_constraints(name: str) -> Union[dict, None]: + if name == "random_type": + return {"possible_values": ["uniform", "exponential", "gaussian", "poisson"]} + + +class PySpiceParser: + """A class to parse PySpice python library + + This class parses the PySpice python library + for attributes of the various components to be used in SPICE simulations + """ + + def __init__(self) -> None: + self._high_level_elements = [] + self._basic_elements = [] + self._confirm_pyspice_existence() + self._meta_dict = {} + + def run(self) -> dict: + import PySpice + + self._parse_basic_elements() + print(f"Found {len(self._basic_elements)} basic elements") + + self._parse_highlevel_elements() + print(f"Found {len(self._high_level_elements)} high level elements") + + return { + "high_level_elements": self._high_level_elements, + "basic_elements": self._basic_elements, + "pyspice_version": str(PySpice.__version__), + } + + def _parse_basic_elements(self) -> None: + pass + + def _parse_highlevel_elements(self) -> None: + from PySpice.Spice import HighLevelElement, _get_elements + + for element in _get_elements(HighLevelElement): + if "Spice.HighLevelElement" in repr(element): + self._high_level_elements.append( + self._parse_high_level_members(element) + ) + + def _parse_high_level_members(self, class_: type) -> dict: + class_details = { + "name": class_.__name__, + "pins_count": 2, + "attributes": [], + "class_doc": class_.__doc__, + } + + for super_class in inspect.getmro(class_): + if "Mixin" in repr(super_class) and "MixinAbc" not in repr(super_class): + mixin_signature = inspect.signature(super_class) + class_details["mixin_doc"] = super_class.__doc__ + meta_dict = self._add_to_meta_dict(class_.__name__, super_class.__doc__) + for param_name, param in mixin_signature.parameters.items(): + if not (val := meta_dict.get(param_name)): + val = _get_missing_attribute_meta(param_name, class_.__name__) + + class_details["attributes"].append( + { + "name": param_name, + "default": self._get_default_for(param_name, param), + "constraints": _get_attribute_constraints(param_name), + "attribute_meta": val if val is not None else {}, + } + ) + + return class_details + + def _add_to_meta_dict(self, class_name: str, class_doc: str) -> dict: + meta_dict = {} + if "TRRANDOM" in class_doc: + return {} + usable_lines = [] + for line in class_doc.split("\n"): + if line.strip().startswith("+") or line.strip().startswith("|"): + if line and "-" not in line: + usable_lines.append( + line.replace("Td1+Tstep", "sum(Td1, Tstep)") + .strip() + .replace("|", "") + .strip() + .replace("+", SPLIT_PLACE_HOLDER) + ) + + if len(usable_lines): + meta_keys = list( + key.strip() for key in usable_lines[0].split(SPLIT_PLACE_HOLDER) + ) + for params in usable_lines[1:]: + params_gen = (val.strip() for val in params.split(SPLIT_PLACE_HOLDER)) + params_dict = {} + param_name = "" + for j, val in enumerate(params_gen): + meta_key = self._to_camel_case(meta_keys[j]) + if meta_key == "parameter": + param_name = self._to_camel_case(val) + + if (freq := "1/sec") in val: + val = val.replace(freq, "Hz") + + if "sum" in val or "," not in val: + params_dict[meta_key] = val + elif "Current" in class_name: + params_dict[self._to_camel_case(meta_keys[j])] = "A" + elif "Voltage" in class_name: + params_dict[self._to_camel_case(meta_keys[j])] = "V" + + meta_dict[param_name] = params_dict + + return meta_dict + + @staticmethod + def _to_camel_case(string: str) -> str: + return "_".join(string.lower().split(" ")).replace(".", "") + + @staticmethod + def _confirm_pyspice_existence() -> None: + from symbench.electric_circuits.utils import has_pyspice + + if not has_pyspice: + raise PySpiceMissingException("PySpice is not installed") + + @staticmethod + def _get_default_for(param_name: str, parameter: inspect.Parameter) -> Any: + if param_name == "values": + return "[(0, 0)]" + if param_name == "random_type": + return "uniform" + if param_name == "damping_factor": + return 0.01 + if param_name in ( + "phase", + "fall_delay_time", + "fall_time_constant", + "rise_time_constant", + ): + return 0 + return ( + parameter.default + if not parameter.default == inspect.Parameter.empty + else 0.0 + ) diff --git a/src/common/plugins/CircuitAnalysisBases.py b/python/symbench/electric_circuits/plugin_bases.py similarity index 99% rename from src/common/plugins/CircuitAnalysisBases.py rename to python/symbench/electric_circuits/plugin_bases.py index c47e6fe..f7bd99a 100644 --- a/src/common/plugins/CircuitAnalysisBases.py +++ b/python/symbench/electric_circuits/plugin_bases.py @@ -4,6 +4,7 @@ from PySpice.Spice.Netlist import Circuit, SubCircuit from PySpice.Unit import * +from symbench.electric_circuits.exceptions import PySpiceConversionError from webgme_bindings import PluginBase # The labels for the components are grabbed from the following source @@ -31,17 +32,13 @@ def get_next_label_for(component: str, component_name: str) -> str: return f"{component_name}_{component_counts[component]}" -class PySpiceConversionError(Exception): - """Error to be raised when there's an error in PySpice conversion""" - - class CircuitToPySpiceBase(PluginBase): """Converts WebGME node of type Circuit to its equivalent PySpice Circuit""" def main(self) -> None: raise NotImplementedError - def convert_to_pyspice(self, circuit: dict) -> None: + def convert_to_pyspice(self, circuit: dict) -> Circuit: """Convert the webgme circuit to PySpice Circuit""" self._assign_meta_functions() pyspice_circuit = None diff --git a/python/symbench/electric_circuits/utils.py b/python/symbench/electric_circuits/utils.py new file mode 100644 index 0000000..a144508 --- /dev/null +++ b/python/symbench/electric_circuits/utils.py @@ -0,0 +1,9 @@ +has_pyspice = False + +try: + import PySpice + + has_pyspice = True + del PySpice +except ImportError: + has_pyspice = False diff --git a/src/plugins/ConvertCircuitToNetlist/ConvertCircuitToNetlist/__init__.py b/src/plugins/ConvertCircuitToNetlist/ConvertCircuitToNetlist/__init__.py index 1ea13b9..dbfbd4e 100644 --- a/src/plugins/ConvertCircuitToNetlist/ConvertCircuitToNetlist/__init__.py +++ b/src/plugins/ConvertCircuitToNetlist/ConvertCircuitToNetlist/__init__.py @@ -1,19 +1,4 @@ -from importlib.util import module_from_spec, spec_from_file_location -from pathlib import Path - -BASE_PLUGIN_PATH = Path( - f"{__file__}/../../../../common/plugins/CircuitAnalysisBases.py" -).resolve() - -IMPORT_MODULE_NAME = "electric_circuits.plugin_bases" -BASE_PLUGIN_NAME = "CircuitToPySpiceBase" - -spec = spec_from_file_location(IMPORT_MODULE_NAME, BASE_PLUGIN_PATH) - -base_module = module_from_spec(spec) -spec.loader.exec_module(base_module) - -PluginBase = getattr(base_module, BASE_PLUGIN_NAME) +from symbench.electric_circuits import CircuitToPySpiceBase as PluginBase class ConvertCircuitToNetlist(PluginBase): diff --git a/src/plugins/RecommendNextComponents/RecommendNextComponents/__init__.py b/src/plugins/RecommendNextComponents/RecommendNextComponents/__init__.py index 2eff7aa..4f6a6ea 100644 --- a/src/plugins/RecommendNextComponents/RecommendNextComponents/__init__.py +++ b/src/plugins/RecommendNextComponents/RecommendNextComponents/__init__.py @@ -5,14 +5,10 @@ from typing import Union from PySpice.Spice.Netlist import Circuit, SubCircuit +from symbench.electric_circuits import AnalyzeCircuit script_dir = path.dirname(path.realpath(__file__)) -BASE_PLUGIN_PATH = Path( - f"{script_dir}/../../../common/plugins/CircuitAnalysisBases.py" -).resolve() -IMPORT_MODULE_NAME = "electric_circuits.plugin_bases" - def import_from_path(path, module_name): spec = spec_from_file_location(module_name, path) @@ -21,9 +17,6 @@ def import_from_path(path, module_name): return module -base_module = import_from_path(BASE_PLUGIN_PATH, IMPORT_MODULE_NAME) -AnalyzeCircuitPlugin = getattr(base_module, "AnalyzeCircuit") - PYSPICE_TO_GME_TYPE = { "SubCircuitElement": "Circuit", "Resistor": "Resistor", @@ -70,7 +63,7 @@ def sort_dict(d): return dict(sorted_keys) -class RecommendNextComponents(AnalyzeCircuitPlugin): +class RecommendNextComponents(AnalyzeCircuit): """Runs a mock implementation for recommending components to be added to the Circuit""" def run_analytics( @@ -91,7 +84,7 @@ def run_analytics( recommendations = sorted(recommendations, key=lambda k: -k[1]) self.add_file("recommendations.json", json.dumps(recommendations, indent=2)) - def _resolve_node(self, node: dict, pin_labels: dict) -> str: + def _resolve_node(self, node: dict, pin_labels: dict) -> dict: inverse_pin_labels = {v: k for (k, v) in pin_labels.items()} return { "type": self._pyspice_to_gme_type(node["type"]),