Commit 8f4eff07 authored by Sean Leavey's avatar Sean Leavey
Browse files

Merge branch 'feature/plotter-objects' into develop

parents 601fbce8 62609035
......@@ -34,9 +34,13 @@ Noise spectral densities
Further modifying plots generated by |Zero|
-------------------------------------------
If you wish to apply further styling to a plot generated by |Zero|, you can do so using
`Matplotlib <https://matplotlib.org/>`__ method calls on the :class:`~matplotlib.figure.Figure`
produced by or provided to the :ref:`plotting methods <plotting/index:Plotting in analysis scripts>`.
If you wish to apply further styling to a plot generated by |Zero|, you can do so using `Matplotlib
<https://matplotlib.org/>`__ method calls on the :attr:`~.MatplotlibPlotter.figure` property of the
:class:`plotter <.MatplotlibPlotter>` object.
You can also set the :class:`.Solution`'s :attr:`~.Solution.response_plotter` and
:attr:`~.Solution.noise_plotter` attributes to custom :class:`.BaseGroupPlotter` implementations to
provide full control over plotting capabilities.
Plotting from the command line
------------------------------
......
......@@ -269,8 +269,8 @@ of an RF summing box with two inputs and one output:
.. hint::
The above example makes a call to :meth:`.plot`. This relies on :ref:`default functions
<solution/index:Default functions>` having been set, in this case by the :ref:`LISO
The above example makes a call to :meth:`~.Solution.plot`. This relies on :ref:`default
functions <solution/index:Default functions>` having been set, in this case by the :ref:`LISO
compatibility module <liso/index:LISO compatibility>`, which is normally not the case when a
circuit is constructed and simulated natively. In such cases, calls to :meth:`.plot_responses`
and :meth:`.plot_noise` with filter parameters are usually required.
......
......@@ -28,5 +28,5 @@ if __name__ == "__main__":
solution = analysis.calculate(frequencies=frequencies, input_type="voltage", node="n1")
# Plot.
solution.plot_responses(sinks=["nm", "nout", "op1"])
solution.show()
plot = solution.plot_responses(sinks=["nm", "nout", "op1"])
plot.show()
......@@ -35,5 +35,5 @@ if __name__ == "__main__":
solution.add_response_reference(frequencies, np.ones_like(frequencies), label="Unity gain")
# Plot, scaling in absolute units.
solution.plot_responses(sink="nout", scale_db=False)
solution.show()
plot = solution.plot_responses(sink="nout", scale_db=False)
plot.show()
......@@ -33,5 +33,5 @@ if __name__ == "__main__":
noise_sum.label = "Total noise"
# Plot.
solution.plot_noise(sink="nout")
solution.show()
plot = solution.plot_noise(sink="nout")
plot.show()
......@@ -31,5 +31,5 @@ if __name__ == "__main__":
"op-amps": "allop"})
# Plot.
solution.plot_noise(sink="nout")
solution.show()
plot = solution.plot_noise(sink="nout")
plot.show()
......@@ -38,5 +38,5 @@ if __name__ == "__main__":
solution.add_noise_reference(frequencies, shot_noise(frequencies, 1e-3), label="Shot noise")
# Plot. Note that the sink is now the input, since we projected the noise there.
solution.plot_noise(sink="input", ylim=(1e-14, 1e-9), title="Photodetector noise")
solution.show()
plot = solution.plot_noise(sink="input", ylim=(1e-14, 1e-9), title="Photodetector noise")
plot.show()
......@@ -38,5 +38,6 @@ if __name__ == "__main__":
solution.scale_noise(pd_to_displacement, sink="input")
# Plot. Note that the sink is now the input, since we projected the noise there.
solution.plot_noise(sink="displacement", ylim=(1e-23, 1e-18), title="Photodetector noise")
solution.show()
plot = solution.plot_noise(sink="displacement", ylim=(1e-23, 1e-18),
title="Photodetector noise")
plot.show()
......@@ -47,5 +47,5 @@ if __name__ == "__main__":
solution = solution1.combine(solution2, solution3)
# Plot
solution.plot_responses(sink="nout", groups="all")
solution.show()
plot = solution.plot_responses(sink="nout", groups="all")
plot.show()
......@@ -12,6 +12,8 @@ from . import __version__, PROGRAM, DESCRIPTION, set_log_verbosity
from .solution import Solution
from .liso import LisoInputParser, LisoOutputParser, LisoRunner, LisoParserError
from .datasheet import PartRequest
from .components import OpAmp
from .display import OpAmpGainPlotter
from .config import (ZeroConfig, OpAmpLibrary, ConfigDoesntExistException,
ConfigAlreadyExistsException, LibraryQueryEngine)
......@@ -206,18 +208,18 @@ def liso(ctx, files, liso, liso_path, resp_scale_db, compare, diff, plot, save_f
if generate_plot:
if solution.has_responses:
figure = solution.plot_responses(scale_db=resp_scale_db)
plotter = solution.plot_responses(scale_db=resp_scale_db)
else:
figure = solution.plot_noise()
plotter = solution.plot_noise()
if save_figure:
for save_path in save_figure:
# NOTE: use figure file's name so that Matplotlib can identify the file type
# appropriately.
solution.save_figure(figure, save_path.name)
plotter.save(save_path.name)
if plot:
solution.show()
plotter.show()
@cli.group()
def library():
......@@ -360,6 +362,39 @@ def library_search(query, sort_a0, sort_gbw, sort_delay, sort_vnoise, sort_vcorn
writer.writerow(engine.parameters)
writer.writerows(rows)
@library.command("opamp")
@click.argument("models", type=str, nargs=-1, metavar="[MODEL]...")
@click.option("--show/--no-show", is_flag=True, default=True, show_default=True,
help="Show op-amp data.")
@click.option("--plot/--no-plot", is_flag=True, default=False,
help="Display open loop gain as figure.")
@click.option("--fstart", type=float, default=1e0, show_default=True, help="Plot start frequency.")
@click.option("--fstop", type=float, default=1e9, show_default=True, help="Plot stop frequency.")
@click.option("--npoints", type=int, default=1000, show_default=True, help="Plot number of points.")
@click.option("--save-figure", type=click.File("wb", lazy=False), multiple=True,
help="Save image of figure to file. Can be specified multiple times.")
def opamp_tools(models, show, plot, fstart, fstop, npoints, save_figure):
opamps = []
for model in models:
library_opamp = LIBRARY.get_opamp(model)
if show:
print(repr(library_opamp))
opamp = OpAmp(model=OpAmpLibrary.format_name(model), node1="input", node2="gnd",
node3="output", **LIBRARY.get_data(model))
opamps.append(opamp)
# Determine whether to generate plot.
generate_plot = plot or save_figure
if generate_plot:
plotter = OpAmpGainPlotter(fstart=fstart, fstop=fstop, npoints=npoints)
plotter.plot(opamps)
if save_figure:
for save_path in save_figure:
# NOTE: use figure file's name so that Matplotlib can identify the file type
# appropriately.
plotter.save(save_path.name)
if plot:
plotter.show()
@cli.group()
def config():
"""Zero configuration functions."""
......
......@@ -55,6 +55,24 @@ class OpAmpLibrary(BaseConfig):
"""
return str(name).upper()
def get_opamp(self, model):
"""Get op-amp by model.
Parameters
----------
model : :class:`str`
The op-amp model.
Returns
-------
:class:`.LibraryOpAmp`
The op-amp.
"""
for opamp in self.opamps:
if opamp.model.upper() == model.upper():
return opamp
raise ValueError(f"op-amp model '{model}' not found in library.")
def get_data(self, name):
"""Get op-amp data.
......@@ -448,3 +466,38 @@ class LibraryOpAmp:
def __str__(self):
return f"{self.model}(a0={self.a0}, gbw={self.gbw}, delay={self.delay})"
def __repr__(self):
def format_poles(poles):
formatted_poles = []
for mag, q in poles:
frequency = Quantity(mag, units="Hz")
if q == 0.5:
q = "real"
else:
q = f"q={q}"
formatted_poles.append(f"{frequency} ({q})")
return ", ".join(formatted_poles)
if self.poles:
poles = format_poles(self.poles_mag_q)
else:
poles = "--"
if self.zeros:
zeros = format_poles(self.zeros_mag_q)
else:
zeros = "--"
return f"""{self.model}
a0: {self.a0}
gbw: {self.gbw}
delay: {self.delay}
vnoise: {self.vnoise}
vcorner: {self.vcorner}
inoise: {self.inoise}
icorner: {self.icorner}
vmax: {self.vmax}
imax: {self.imax}
sr: {self.sr}
poles: {poles}
zeros: {zeros}
"""
......@@ -2,17 +2,48 @@
import abc
from importlib import import_module
import logging
import collections
import tempfile
import colorsys
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import colors, cycler
from matplotlib.ticker import MultipleLocator
from tabulate import tabulate
from .config import ZeroConfig
from .components import Resistor, Capacitor, Inductor, OpAmp, Input, Component, Node
from .data import Response, Series, MultiNoiseDensity
LOGGER = logging.getLogger(__name__)
CONF = ZeroConfig()
def lighten_colours(colour_cycle, factor):
"""Lightens the given color by multiplying (1 - luminosity) by the given factor.
https://stackoverflow.com/a/49601444/2251982
"""
cycle = []
for this_colour in colour_cycle:
try:
# get RGB values from hex string or name
c = colors.cnames[this_colour]
except KeyError:
c = this_colour
c = colorsys.rgb_to_hls(*colors.to_rgb(c))
new = colorsys.hls_to_rgb(c[0], 1 - factor * (1 - c[1]), c[2])
newints = tuple([int(value * 255) for value in new])
hexcode = "#%02x%02x%02x" % newints
cycle.append(hexcode)
return cycle
class NodeGraph:
# input shapes per input type
input_shapes = {"noise": "ellipse", "voltage": "box", "current": "pentagon"}
......@@ -497,3 +528,289 @@ class EquationDisplay(TableFormatter):
expression += r"\end{align}"
return expression
class BasePlotter(metaclass=abc.ABCMeta):
@abc.abstractmethod
def plot(self, functions, **kwargs):
raise NotImplementedError
@abc.abstractmethod
def show(self):
raise NotImplementedError
class BaseGroupPlotter(metaclass=abc.ABCMeta):
def __init__(self, legend_groups=True, hidden_group_names=None, **kwargs):
super().__init__(**kwargs)
self.legend_groups = legend_groups
if hidden_group_names is None:
hidden_group_names = []
self.hidden_group_names = list(hidden_group_names)
@abc.abstractmethod
def plot_groups(self, groups):
raise NotImplementedError
class MatplotlibPlotter(BasePlotter, metaclass=abc.ABCMeta):
def __init__(self, figure=None, title=None, legend=True, legend_loc="best", **kwargs):
super().__init__(**kwargs)
# Defaults.
self._figure = None
# Parameters.
if figure is not None:
self.figure = figure
self.title = title
self.legend = legend
self.legend_loc = legend_loc
@property
def figure(self):
if self._figure is None:
self._figure = self._create_figure()
return self._figure
@figure.setter
def figure(self, figure):
self._figure = figure
def _create_figure(self):
figure = plt.figure(figsize=(float(CONF["plot"]["size_x"]), float(CONF["plot"]["size_y"])))
LOGGER.info("figure created on %s", figure.canvas.get_window_title())
return figure
def show(self, tight_layout=True):
if tight_layout:
plt.tight_layout()
plt.show()
def save(self, path, **kwargs):
"""Save specified figure to specified path (path can be file object or string path)."""
# Set figure as current figure.
plt.figure(self.figure.number)
# Squeeze things together.
self.figure.tight_layout()
plt.savefig(path, **kwargs)
class MplGroupPlotter(MatplotlibPlotter, BaseGroupPlotter, metaclass=abc.ABCMeta):
"""Provides interface for plotting grouped functions."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.style_groups = []
# Plot group line style cycle.
self.linestyles = ["-", "--", "-.", ":"]
# Default colour cycle.
self.default_color_cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]
# Cycles by group. These are created at runtime using the default colour cycle and the
# lighten_colours() function.
self._plot_group_colours = {}
def plot_groups(self, groups):
for group, functions in groups.items():
if not functions:
# Skip empty group.
continue
with self._figure_style_context(group):
# Reset axes colour wheels.
for axis in self.figure.axes:
axis.set_prop_cycle(plt.rcParams["axes.prop_cycle"])
if self.legend_groups and group not in self.hidden_group_names:
# Show group.
legend_group = "(%s)" % group
else:
legend_group = None
self.plot(functions, label_suffix=legend_group)
def _figure_style_context(self, group):
"""Figure style context manager.
Used to override the default style for a figure.
"""
# Find group index.
if group not in self.style_groups:
self.style_groups.append(group)
group_index = self.style_groups.index(group)
# Get index of linestyle to use (cycles through styles, wrapping back to beginning).
index = group_index % len(self.linestyles)
if group not in self._plot_group_colours:
# Create new cycle with brighter colours.
cycle = lighten_colours(self.default_color_cycle, 0.5 ** group_index)
self._plot_group_colours[group] = cycle
prop_cycler = cycler(color=self._plot_group_colours[group])
settings = {"lines.linestyle": self.linestyles[index],
"axes.prop_cycle": prop_cycler}
return plt.rc_context(settings)
def _axis_grayscale_context(self):
"""Sum figure style context manager. This sets the sum colors to greyscale."""
return plt.rc_context({"axes.prop_cycle": cycler(color=self._grayscale_colours)})
@property
def _grayscale_colours(self):
"""Grayscale colour palette."""
greys = plt.get_cmap('Greys')
return greys(np.linspace(CONF["plot"]["sum_greyscale_cycle_start"],
CONF["plot"]["sum_greyscale_cycle_stop"],
CONF["plot"]["sum_greyscale_cycle_count"]))
class BodePlotter(MplGroupPlotter):
def __init__(self, scale_db=True, xlim=None, mag_ylim=None, phase_ylim=None, xlabel=None,
ylabel_mag=None, ylabel_phase=None, db_tick_major_step=20, db_tick_minor_step=10,
phase_tick_major_step=30, phase_tick_minor_step=15, **kwargs):
super().__init__(**kwargs)
self.scale_db = scale_db
self.xlim = xlim
self.mag_ylim = mag_ylim
self.phase_ylim = phase_ylim
if xlabel is None:
xlabel = r"$\bf{Frequency}$ (Hz)"
if ylabel_mag is None:
if scale_db:
ylabel_mag = r"$\bf{Magnitude}$ (dB)"
else:
ylabel_mag = r"$\bf{Magnitude}$"
if ylabel_phase is None:
ylabel_phase = r"$\bf{Phase}$ ($\degree$)"
self.xlabel = xlabel
self.ylabel_mag = ylabel_mag
self.ylabel_phase = ylabel_phase
self.db_tick_major_step = db_tick_major_step
self.db_tick_minor_step = db_tick_minor_step
self.phase_tick_major_step = phase_tick_major_step
self.phase_tick_minor_step = phase_tick_minor_step
def _create_figure(self):
figure = super()._create_figure()
# Add magnitude and phase axes.
ax1 = figure.add_subplot(211)
ax2 = figure.add_subplot(212, sharex=ax1)
# Draw labels etc.
if self.title is not None:
# Use ax1 since it's at the top. We could use figure.suptitle but this doesn't
# behave with tight_layout.
ax1.set_title(self.title)
if self.legend:
ax1.legend(loc=self.legend_loc)
if self.xlim is not None:
ax1.set_xlim(self.xlim)
ax2.set_xlim(self.xlim)
if self.mag_ylim is not None:
ax1.set_ylim(self.mag_ylim)
if self.phase_ylim is not None:
ax2.set_ylim(self.phase_ylim)
if self.xlabel is not None:
ax2.set_xlabel(self.xlabel)
if self.ylabel_mag is not None:
ax1.set_ylabel(self.ylabel_mag)
if self.ylabel_phase is not None:
ax2.set_ylabel(self.ylabel_phase)
ax1.grid(zorder=CONF["plot"]["grid_zorder"])
ax2.grid(zorder=CONF["plot"]["grid_zorder"])
# Magnitude and phase tick locators.
if self.scale_db:
ax1.yaxis.set_major_locator(MultipleLocator(base=self.db_tick_major_step))
ax1.yaxis.set_minor_locator(MultipleLocator(base=self.db_tick_minor_step))
ax2.yaxis.set_major_locator(MultipleLocator(base=self.phase_tick_major_step))
ax2.yaxis.set_minor_locator(MultipleLocator(base=self.phase_tick_minor_step))
return figure
@MplGroupPlotter.figure.setter
def figure(self, figure):
if len(figure.axes) != 2:
raise ValueError("figure must contain two axes")
self._figure = figure
def plot(self, functions, **kwargs):
ax1, ax2 = self.figure.axes
for response in functions:
response.draw(ax1, ax2, scale_db=self.scale_db, **kwargs)
# Add new labels to legend.
ax1.legend()
class SpectralDensityPlotter(MplGroupPlotter):
def __init__(self, xlim=None, ylim=None, xlabel=None, ylabel=None, **kwargs):
super().__init__(**kwargs)
self.xlim = xlim
self.ylim = ylim
if xlabel is None:
xlabel = r"$\bf{Frequency}$ (Hz)"
self.xlabel = xlabel
if ylabel is None:
ylabel = r"$\bf{Noise}$"
self.ylabel = ylabel
@property
def axis(self):
return self.figure.axes[0]
def _create_figure(self):
figure = super()._create_figure()
axis = figure.add_subplot(111)
# Draw labels etc.
if self.title is not None:
# Use ax1 since it's at the top. We could use figure.suptitle but this doesn't
# behave with tight_layout.
axis.set_title(self.title)
if self.legend:
axis.legend(loc=self.legend_loc)
if self.xlim is not None:
axis.set_xlim(self.xlim)
if self.ylim is not None:
axis.set_ylim(self.ylim)
if self.xlabel is not None:
axis.set_xlabel(self.xlabel)
if self.ylabel is not None:
axis.set_ylabel(self.ylabel)
axis.grid(zorder=CONF["plot"]["grid_zorder"])
# Magnitude and phase tick locators.
return figure
@MplGroupPlotter.figure.setter
def figure(self, figure):
if len(figure.axes) != 1:
raise ValueError("figure must contain one axis")
self._figure = figure
def plot(self, functions, **kwargs):
sums = []
for spectral_density in functions:
if isinstance(spectral_density, MultiNoiseDensity):
# Leave to end as we need to set a new prop cycler on the axis.
sums.append(spectral_density)
continue
spectral_density.draw(self.axis, **kwargs)
with self._axis_grayscale_context():
self.axis.set_prop_cycle(plt.rcParams["axes.prop_cycle"])
for sum_spectral_density in sums:
sum_spectral_density.draw(self.axis, **kwargs)
# Add label to legend.
self.axis.legend()
class OpAmpGainPlotter(BodePlotter):
def __init__(self, frequencies=None, fstart=None, fstop=None, npoints=1000,
title="Open loop gain"):
super().__init__(title=title)
if frequencies is None:
if any([param is None for param in (fstart, fstop, npoints)]):
raise ValueError("either frequencies, or all of fstart, fstop and npoints must be "
"specified")
frequencies = np.logspace(np.log10(fstart), np.log10(fstop), npoints)
self.frequencies = np.array(frequencies)
def response(self, opamp):
gain = np.array([opamp.gain(frequency) for frequency in self.frequencies])
series = Series(self.frequencies, gain)
response = Response(source=opamp.node1, sink=opamp.node3, series=series)
response.label = opamp.model
return response
def plot(self, opamps):
super().plot([self.response(opamp) for opamp in opamps])
def show(self):
plt.show()
......@@ -4,11 +4,9 @@ import sys
import os
import abc
import tempfile
import colorsys
import numpy as np
import requests
import progressbar
from matplotlib import colors
class Singleton(abc.ABCMeta):
......@@ -145,29 +143,6 @@ class Downloadable:
return filename, request
def lighten_colours(colour_cycle, factor):
"""Lightens the given color by multiplying (1 - luminosity) by the given factor.
https://stackoverflow.com/a/49601444/2251982
"""
cycle = []
for this_colour in colour_cycle:
try:
# get RGB values from hex string or name
c = colors.cnames[this_colour]
except KeyError:
c = this_colour
c = colorsys.rgb_to_hls(*colors.to_rgb(c))
new = colorsys.hls_to_rgb(c[0], 1 - factor * (1 - c[1]), c[2])
newints = tuple([int(value * 255) for value in new])
hexcode = "#%02x%02x%02x" % newints
cycle.append(hexcode)
return cycle