Skip to content
Snippets Groups Projects
Commit 6a043c0a authored by Sean Leavey's avatar Sean Leavey
Browse files

Merge branch 'release-0.5.2'

parents c46b3ee1 ef78d3b4
No related branches found
Tags 0.5.2
No related merge requests found
Pipeline #37519 passed
# Zero
Linear electronic circuit simulator utility. This package provides tools to
Linear electronic circuit utility. This package provides tools to
simulate transfer functions and noise in linear electronic circuits, SI unit
parsing and formatting and more.
parsing and formatting, datasheet grabbing, and more.
This tool is inspired by [LISO](https://wiki.projekt.uni-hannover.de/aei-geo-q/start/software/liso).
This tool's simulator is inspired by [LISO](https://wiki.projekt.uni-hannover.de/aei-geo-q/start/software/liso).
It also (somewhat) understands LISO input and output files, and can plot or
re-simulate their contents.
......
......@@ -4,10 +4,10 @@ AC analyses
===========
The available AC analyses are performed assuming the circuit to be linear time invariant (LTI),
meanoing that the parameters of the components within the circuit, and the circuit itself, cannot
change over time. This is a reasonable assumption to make for circuits containing passive components
such as resistors, capacitors and inductors, and op-amps far from saturation, and where only the
frequency response or noise spectral density is required to be computed.
meaning that the parameters of the components within the circuit, and the circuit itself, cannot
change over time. This is usually a reasonable assumption to make for circuits containing passive
components such as resistors, capacitors and inductors, and op-amps far from saturation, and where
only the frequency response or noise spectral density is required to be computed.
The linearity property implies the principle of superposition, such that if you double the circuit's
input voltage, the current through each component will also double. This restriction implies
......
.. include:: /defs.txt
##########
Datasheets
##########
|Zero|'s command line interface can be used to download and display datasheets using
`Octopart <https://octopart.com/>`__'s API.
Searching for parts
-------------------
Specify a search term like this:
.. code-block:: bash
$ zero datasheet "OP27"
Partial matches are made based on the search string by default. To force exact matches only,
specify ``--exact``.
If there are multiple parts found, a list is displayed and the user is prompted to enter a
number corresponding to the part to display. Once a part is selected, either its datasheet is
shown or, in the event that there are multiple datasheets available for the specified part, the
user is again prompted to choose a datasheet.
The selected datasheet is downloaded and displayed using the default viwer. To download the
datasheet without displaying it, use the ``--download-only`` flag and set the ``--path`` option to
the path to save the file. If no ``--path`` is specified, the datasheet is saved to a temporary
location and the location is printed to the screen.
To download and display the first part's latest datasheet, specify the ``--first`` flag, e.g.:
.. code-block:: bash
$ zero datasheet "OP27" --first
This will immediately download and display the latest OP27 datasheet.
Updating the API endpoint and key
---------------------------------
|Zero| comes bundled with an API key which is open to use for any users. If for some reason this
API key is no longer available, a new key can be specified in the
:ref:`configuration file <configuration/index:Configuration>`.
Command reference
-----------------
.. click:: zero.__main__:datasheet
:prog: zero datasheet
:show-nested:
......@@ -4,8 +4,11 @@
Command line interface
######################
|Zero| provides a command line interface to perform some common tasks, mainly focused on the
running, display and comparison of LISO scripts.
|Zero| provides a command line interface to perform some common tasks:
- :ref:`Run LISO scripts <cli/liso:LISO tools>`
- :ref:`Parametrically search the op-amp library <cli/opamp-library:Op-amp library tools>`
- :ref:`Download and display datasheets <cli/datasheets:Datasheets>`
===========
Subcommands
......@@ -16,6 +19,7 @@ Subcommands
liso
opamp-library
datasheets
====================
Command line options
......
.. include:: /defs.txt
#############
Configuration
#############
User-supplied settings can be specified in a configuration file. The location of this file varies
depending on the operating system being used to run |Zero|.
Octopart
API endpoint and key settings for accessing datasheets.
api_endpoint
API endpoint URL.
api_key
API key.
......@@ -85,5 +85,6 @@ Contents
examples/index
liso/index
cli/index
configuration/index
API reference <api/modules>
contributing/index
......@@ -7,6 +7,7 @@ REQUIREMENTS = [
"numpy",
"scipy",
"matplotlib",
"requests",
"progressbar2",
"tabulate",
"setuptools_scm",
......@@ -30,7 +31,9 @@ EXTRAS = {
setup(
name="zero",
use_scm_version=True,
use_scm_version={
"write_to": "zero/_version.py"
},
description="Linear circuit simulator",
long_description=README,
author="Sean Leavey",
......
import logging
import locale
from pkg_resources import get_distribution, DistributionNotFound
PROGRAM = "zero"
DESCRIPTION = "Linear circuit simulator"
# get version
try:
__version__ = get_distribution(__name__).version
except DistributionNotFound:
from ._version import version as __version__
except ImportError:
# packaging resources are not installed
__version__ = '?'
__version__ = '?.?.?'
try:
from matplotlib import rcParams
......
"""Circuit simulator command line interface"""
import sys
import logging
from click import Path, File, group, argument, option, version_option, pass_context
from click import Path, File, IntRange, group, argument, option, version_option, pass_context
from tabulate import tabulate
from . import __version__, PROGRAM, DESCRIPTION, set_log_verbosity
from .liso import LisoInputParser, LisoOutputParser, LisoRunner, LisoParserError
from .datasheet import PartRequest
from .config import ZeroConfig
from .library import LibraryQueryEngine
......@@ -37,6 +39,9 @@ class State:
set_log_verbosity(self._verbosity)
# write some debug info now that we've set up the logger
LOGGER.debug("%s %s", PROGRAM, __version__)
@property
def verbose(self):
"""Verbose output enabled
......@@ -45,6 +50,18 @@ class State:
"""
return self.verbosity <= logging.INFO
def _print(self, msg, stream, exit_, exit_code):
print(msg, file=stream)
if exit_:
sys.exit(exit_code)
def print_info(self, msg, exit_=False):
self._print(msg, sys.stdout, exit_, 0)
def print_error(self, msg, exit_=True):
self._print(msg, sys.stderr, exit_, 1)
def set_verbosity(ctx, _, value):
"""Set stdout verbosity"""
......@@ -246,3 +263,90 @@ def opamp(ctx, query, a0, gbw, vnoise, vcorner, inoise, icorner, vmax, imax, sr)
rows.append(row)
print(tabulate(rows, header, tablefmt=CONF["format"]["table"]))
@cli.command()
@argument("term")
@option("-f", "--first", is_flag=True, default=False,
help="Download first match without further prompts.")
@option("--partial/--exact", is_flag=True, default=True, help="Allow partial matches.")
@option("--display/--download-only", is_flag=True, default=True,
help="Display the downloaded file.")
@option("-p", "--path", type=Path(writable=True),
help="File or directory in which to save the first found datasheet.")
@option("-t", "--timeout", type=IntRange(0), help="Request timeout in seconds.")
@pass_context
def datasheet(ctx, term, first, partial, display, path, timeout):
"""Search, fetch and display datasheets."""
state = ctx.ensure_object(State)
# get parts
parts = PartRequest(term, partial=partial, path=path, timeout=timeout, progress=state.verbose)
if not parts:
state.print_error("No parts found")
if first or len(parts) == 1:
# latest part
part = parts.latest_part
# show results directly
state.print_info(part)
else:
state.print_info("Found multiple parts:")
for index, part in enumerate(parts, 1):
state.print_info("%d: %s" % (index, part))
chosen_part_idx = 0
while chosen_part_idx <= 0 or chosen_part_idx > len(parts):
try:
chosen_part_idx = int(input("Enter part number: "))
if chosen_part_idx <= 0 or chosen_part_idx > len(parts):
raise ValueError
except ValueError:
state.print_error("Invalid, try again", exit=False)
# get chosen datasheet
part = parts[chosen_part_idx - 1]
# get chosen part
if part.n_datasheets == 0:
state.print_error("No datasheets found for '%s'" % part.mpn)
if first or part.n_datasheets == 1:
# show results directly
state.print_info(part)
# get datasheet
ds = part.latest_datasheet
else:
state.print_info("Found multiple datasheets:")
for index, ds in enumerate(part.sorted_datasheets, 1):
state.print_info("%d: %s" % (index, ds))
chosen_datasheet_idx = 0
while chosen_datasheet_idx <= 0 or chosen_datasheet_idx > part.n_datasheets:
try:
chosen_datasheet_idx = int(input("Enter datasheet number: "))
if chosen_datasheet_idx <= 0 or chosen_datasheet_idx > part.n_datasheets:
raise ValueError
except ValueError:
state.print_error("Invalid, try again", exit=False)
# get datasheet
ds = part.datasheets[chosen_datasheet_idx - 1]
# display details
if ds.created is not None:
LOGGER.debug("Created: %s", ds.created)
if ds.n_pages is not None:
LOGGER.debug("Pages: %d", ds.n_pages)
if ds.url is not None:
LOGGER.debug("URL: %s", ds.url)
ds.download()
LOGGER.debug("Saved to: %s", ds.path)
if display:
ds.display()
from .fetch import PartRequest
"""Datasheet fetcher"""
import logging
import json
from .parts import Part, nonesorter
from ..misc import Downloadable
from ..config import ZeroConfig
LOGGER = logging.getLogger(__name__)
CONF = ZeroConfig()
class PartRequest(Downloadable, list):
"""Part request handler"""
def __init__(self, keyword, partial=True, path=None, timeout=None, **kwargs):
super().__init__(**kwargs)
self.keyword = keyword
self.partial = partial
self.path = path
self.timeout = timeout
self._request()
def _request(self):
"""Request datasheet"""
# build parameters
params = {"include[]": "datasheets",
"queries": self.search_query}
# add defaults
params = {**params, **self.default_params}
# get parts
self._handle_response(*self.fetch(CONF["octopart"]["api_endpoint"], params=params,
label="Downloading part information"))
def _handle_response(self, data, response):
"""Handle response"""
if response.status_code != 200:
raise Exception(response)
if "application/json" not in response.headers["content-type"]:
raise Exception("unknown response content type")
response_data = json.loads(data)
# debug info
LOGGER.debug("request took %d ms", response_data["msec"])
if not "results" in response_data:
raise Exception("unexpected response")
# first list item in results
results = next(iter(response_data["results"]))
# parts
parts = results["items"]
LOGGER.debug("%d %s found", len(parts), ["part", "parts"][len(parts) != 1])
# store parts
self._parse_parts(parts)
def _parse_parts(self, raw_parts):
"""Parse parts"""
for part in raw_parts:
self.append(Part(part, path=self.path, timeout=self.timeout, progress=self.progress))
@property
def search_query(self):
"""Search query JSON string"""
keyword = self.keyword
if self.partial:
keyword = "*%s*" % keyword
return json.dumps([{"mpn": keyword}])
@property
def default_params(self):
"""Default parameters to include in every request"""
return {"apikey": CONF["octopart"]["api_key"]}
@property
def latest_part(self):
# sort by latest datasheet
parts = sorted(self, reverse=True, key=lambda part: nonesorter(part.latest_datasheet))
return next(iter(parts), None)
"""Electronic parts and datasheets"""
import os
import sys
import subprocess
import re
import datetime
import logging
import dateutil.parser
from ..misc import Downloadable
LOGGER = logging.getLogger(__name__)
class Part:
def __init__(self, part_info, path=None, timeout=None, progress=False):
self.path = path
self.timeout = timeout
self.progress = progress
self.brand = None
self.brand_url = None
self.manufacturer = None
self.manufacturer_url = None
self.mpn = None
self.url = None
self.datasheets = []
self._parse(part_info)
def _parse(self, part_info):
if part_info.get("brand"):
if part_info["brand"].get("name"):
self.brand = part_info["brand"]["name"]
if part_info["brand"].get("homepage_url"):
self.brand_url = part_info["brand"]["homepage_url"]
if part_info.get("manufacturer"):
if part_info["manufacturer"].get("name"):
self.manufacturer = part_info["manufacturer"]["name"]
if part_info["manufacturer"].get("homepage_url"):
self.manufacturer_url = part_info["manufacturer"]["homepage_url"]
if part_info.get("mpn"):
self.mpn = part_info["mpn"]
if part_info.get("octopart_url"):
self.url = part_info["octopart_url"]
if part_info.get("datasheets"):
self.datasheets = [Datasheet(datasheet, part_name=self.mpn, path=self.path,
timeout=self.timeout, progress=self.progress)
for datasheet in part_info["datasheets"]]
@property
def n_datasheets(self):
return len(self.datasheets)
@property
def sorted_datasheets(self):
# order datasheets
return sorted(self.datasheets, reverse=True, key=nonesorter)
@property
def latest_datasheet(self):
return next(iter(self.sorted_datasheets), None)
def __repr__(self):
return "{brand} / {manufacturer} {mpn}".format(**self.__dict__)
class Datasheet(Downloadable):
def __init__(self, datasheet_data, part_name=None, path=None, **kwargs):
"""Datasheet.
Parameters
----------
path : :class:`str` or file
Path to store downloaded file. If a directory is specified, the downloaded part name is
used.
"""
super().__init__(**kwargs)
self.part_name = part_name
self.path = path
self.created = None
self.n_pages = None
self.url = None
# flag for whether datasheet PDF has been downloaded
self._downloaded = False
self._parse(datasheet_data)
def _parse(self, datasheet_data):
if datasheet_data.get("metadata"):
if datasheet_data["metadata"].get("date_created"):
self.created = dateutil.parser.parse(datasheet_data["metadata"]["date_created"])
if datasheet_data["metadata"].get("num_pages"):
self.n_pages = int(datasheet_data["metadata"]["num_pages"])
self.url = datasheet_data["url"]
@property
def full_path(self):
"""Get path to store datasheet including filename, or None if no path is set"""
path = self.path
if os.path.isdir(path):
# add filename
path = os.path.join(path, self.safe_filename)
return path
@property
def safe_part_name(self):
"""Sanitise part name, generating one if one doesn't exist"""
part_name = self.part_name
if self.part_name is None:
part_name = "unknown"
return part_name
@property
def safe_filename(self):
"""Sanitise filename for storing on the file system"""
filename = self.safe_part_name
filename = str(filename).strip().replace(' ', '_')
filename = re.sub(r'(?u)[^-\w.]', '', filename)
# add extension
filename = filename + os.path.extsep + "pdf"
return filename
def download(self, force=False):
if self._downloaded and not force:
# already downloaded
return
filename, _ = self.fetch_file(url=self.url, filename=self.path,
label="Downloading %s" % self.part_name)
# update path, if necessary
self.path = filename
self._downloaded = True
def display(self):
self.download()
self._open_pdf(self.path)
def _open_pdf(self, filename):
if sys.platform == "win32":
os.startfile(filename)
else:
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.run([opener, filename])
def __str__(self):
if self.created is not None:
created = self.created.strftime("%Y-%m-%d")
else:
created = "?"
if self.n_pages is not None:
pages = self.n_pages
else:
pages = "unknown"
return "Datasheet (created %s, %s pages)" % (created, pages)
def nonesorter(datasheet):
"""Return datasheet creation date, or minimum time, for the purposes of sorting."""
if getattr(datasheet, "created", None) is None:
# use minimum date
zero = datetime.datetime.min
zero.replace(tzinfo=None)
return zero
return datasheet.created.replace(tzinfo=None)
"""Miscellaneous functions"""
import sys
import os
import abc
import tempfile
import numpy as np
import requests
import progressbar
class Singleton(abc.ABCMeta):
......@@ -46,6 +51,81 @@ class NamedInstance(abc.ABCMeta):
return cls._names[name]
class Downloadable:
"""Mixin for downloadable URL classes, providing a progress bar."""
def __init__(self, info_stream=sys.stdout, progress=True, timeout=None, **kwargs):
super().__init__(**kwargs)
self.info_stream = info_stream
self.progress = progress
self.timeout = timeout
def fetch(self, *args, **kwargs):
filename, request = self.fetch_file(*args, **kwargs)
with open(filename, "r") as file_handler:
data = file_handler.read()
return data, request
def fetch_file(self, url, filename=None, params=None, label=None):
if self.progress:
info_stream = self.info_stream
else:
# null file
info_stream = open(os.devnull, "w")
if label is None:
label = "Downloading"
label += ": "
pbar = progressbar.ProgressBar(widgets=[label,
progressbar.Percentage(),
progressbar.Bar(),
progressbar.ETA()],
max_value=100, fd=info_stream).start()
timeout = self.timeout
if timeout == 0:
# avoid invalid timeout
timeout = None
# make request
request = requests.get(url, params=params, stream=True, timeout=timeout)
total_data_length = int(request.headers.get("content-length"))
data_length = 0
if filename is None:
# create temporary file
tmp_file = tempfile.NamedTemporaryFile(delete=False)
filename = tmp_file.name
with open(filename, "wb") as file_handler:
for chunk in request.iter_content(chunk_size=128):
if chunk:
file_handler.write(chunk)
data_length += len(chunk)
if data_length == total_data_length:
fraction = 100
else:
fraction = 100 * data_length / total_data_length
# check in case lengths are misreported
if fraction > 100:
fraction = 100
elif fraction < 0:
fraction = 0
pbar.update(fraction)
pbar.finish()
return filename, request
def db(magnitude):
"""Calculate (power) magnitude in decibels
......
......@@ -30,3 +30,8 @@ node_style = filled
node_font_name = Helvetica
node_font_size = 10
edge_arrowhead = dot
[octopart]
# Octopart API settings, for fetching datasheets
api_endpoint = https://octopart.com/api/v3/parts/match
api_key = ebdc07fc
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment