Commit d46d54bf authored by Sean Leavey's avatar Sean Leavey

Merge branch 'release/0.6.4'

parents 4d7e08cd 6badd1e7
Pipeline #64881 canceled with stage
in 4 minutes and 42 seconds
......@@ -111,11 +111,10 @@ test validation:3.7:
stage: test
<<: *template-test-validation
# Generate the documentation only on creation of new tags or pushes to master branch.
# Generate the documentation only on creation of new tags.
pages:
image: python:3.7
stage: deploy
only:
- tag
- master
<<: *template-deploy-docs
......@@ -129,6 +129,5 @@ Noise analysis requires an essentially identical approach to building the circui
that the matrix is transposed and the right hand side is given a ``1`` in the row corresponding to
the chosen noise output node instead of the input. This results in the solution ``x`` in the matrix
equation ``Ax = b`` instead providing what amounts to the reverse responses between the component
component and nodes in the circuit and the chosen noise output node. These reverse responses are
as a last step multiplied by the noise at each component and node to infer the noise at the noise
output node.
and nodes in the circuit and the chosen noise output node. These reverse responses are as a last
step multiplied by the noise at each component and node to infer the noise at the noise output node.
.. include:: /defs.txt
.. currentmodule:: zero.components
Components
==========
......@@ -16,17 +18,78 @@ Components
What is a 'component'?
----------------------
A :class:`component <.Component>` represents a circuit device which sources or
sinks current, and produces voltage drops between its :class:`nodes <.Node>`.
:class:`Passive <.PassiveComponent>` components such as :class:`resistors <.Resistor>`,
:class:`capacitors <.Capacitor>` and :class:`inductors <.Inductor>` do not produce or
amplify signals, but only apply an impedance to their input. Active components such as
:class:`op-amps <.OpAmp>` can source current.
A :class:`component <.Component>` represents a circuit device which sources or sinks current, and
produces voltage drops between its :class:`nodes <.Node>`. :class:`Passive <.PassiveComponent>`
components such as :class:`resistors <.Resistor>`, :class:`capacitors <.Capacitor>` and
:class:`inductors <.Inductor>` do not produce or amplify signals, but only apply an impedance to
their input. Active components such as :class:`op-amps <.OpAmp>` can source current.
Instantiated components may be added to :class:`circuits <.Circuit>` using
Instantiated components may be added to :ref:`circuits <circuit/index:Circuits>` using
:meth:`.add_component`; however, the methods :meth:`.add_resistor`, :meth:`.add_capacitor`,
:meth:`.add_inductor` and :meth:`.add_opamp` allow components to be created and added to
a circuit at the same time.
:meth:`.add_inductor` and :meth:`.add_opamp` allow components to be created and added to a circuit
at the same time, and avoid the need to import them directly.
.. note::
The recommended way to add components to a circuit is to use the :meth:`.add_resistor`,
:meth:`.add_capacitor`, :meth:`.add_inductor` and :meth:`.add_opamp` methods provided by
:class:`.Circuit`. These offer the same functionality as when creating
component objects directly, but avoid the need to directly import the component classes into
your script.
Component names
---------------
Components may be provided with a name on creation using the ``name`` keyword argument, i.e.
.. code-block:: python
>>> r = Resistor(name="r1", value="430k", node1="n1", node2="n2")
or
.. code-block:: python
>>> from zero import Circuit
>>> circuit = Circuit()
>>> circuit.add_resistor(name="rin", value="430k", node1="n1", node2="n2")
Names can also be set using the :attr:`~.Component.name` property:
.. code-block:: python
>>> r.name = "r1"
Component names can be used to retrieve components from circuits:
.. code-block:: python
>>> r = circuit["rin"]
>>> print(r)
rin [in=n1, out=n2, R=430.00k]
Component names must be unique within a given circuit. When trying to add a component to a circuit
where its name is already used by another circuit component, a :class:`ValueError` is raised.
.. note::
Component names do not need to be unique within the global namespace. That means components with
different values or nodes can have the same name as long as they are not part of the same
circuit.
Naming of components is not required; however, when a component is added to a circuit it is assigned
a name if it does not yet have one. This name uses a prefix followed by a number (the lowest
positive integer not resulting in a name which matches that of a component already present in the
circuit). The character(s) used depend on the component type:
========= ====== =======
Component Prefix Example
========= ====== =======
Resistor r r1
Capacitor c c1
Inductor l l1
Op-amp op op1
========= ====== =======
Component noise sources
-----------------------
......@@ -49,7 +112,6 @@ You can then set the value using the object's :attr:`~.PassiveComponent.value` a
.. code:: python
# string
c1.value = "1u"
In the above example, the string is parsed parsed by :class:`.Quantity` into an appropriate
......@@ -57,14 +119,13 @@ In the above example, the string is parsed parsed by :class:`.Quantity` into an
.. code:: python
# float
c1.value = 1e-6
You may also provide a string with units or scales:
.. code:: python
# string with scale factor and unit
# Quantity with scale factor and unit.
c1.value = "2.2nF"
The above value is parsed as ``2.2e-9``, with unit ``F``. The unit is stored alongside the numeric
......
.. include:: /defs.txt
.. currentmodule:: zero.data
###############
Data containers
###############
......@@ -23,11 +25,48 @@ Responses
:class:`Responses <.data.Response>` contain the response of a component or node to another component
or node. Each response contains references to the source and sink component or node, and its units.
Noise spectra
-------------
The response's underlying complex data is stored in its :attr:`~.Response.complex_magnitude`
property. The magnitude and phase can be retrieved using the :attr:`~.Response.magnitude` and
:attr:`~.Response.phase` properties, respectively.
.. note::
The :attr:`~.Response.magnitude` is returned with decibel (power) scaling, i.e. :math:`20 \log_{10} \left| x \right|`
where :math:`x` is the complex response. The :attr:`~.Response.phase` is returned in units of
(unwrapped) degrees.
.. code-block:: python
>>> response.complex_magnitude
array([-1.44905660e+06+271698.11320755j, -1.28956730e+06+520929.0994604j ,
-8.53524671e+05+742820.7338082j , -3.32179931e+05+622837.37024221j,
-8.66146537e+04+349885.52751013j, -1.95460509e+04+170108.87173014j,
-4.25456479e+03 +79773.08987768j, -9.18662496e+02 +37109.9690498j ,
-1.98014980e+02 +17233.2022651j , -4.26654531e+01 +7999.77245092j])
>>> response.magnitude
array([123.37176609, 122.86535272, 121.07307338, 116.97464649,
111.13682633, 104.67150284, 98.04946401, 91.39247246,
84.72789306, 78.06167621])
>>> response.phase
array([169.38034472, 158.00343425, 138.96701713, 118.07248694,
103.90412599, 96.55472157, 93.05288251, 91.41807544,
90.65831778, 90.30557459])
:class:`Noise spectra <.data.NoiseDensity>` contain the noise at a particular component or node
arising from noise produced by another component or node. They contain the :class:`noise source <.components.Noise>`
Noise spectral densities
------------------------
:class:`Noise spectral densities <.data.NoiseDensity>` contain the noise at a particular component
or node arising from noise produced by another component or node. They contain the :class:`noise source <.components.Noise>`
that produces the noise and a reference to the component or node that the noise is measured at, and
its units. :class:`Multi-noise spectra <.data.MultiNoiseDensity>` contain a list of multiple noise
sources; these are used to represent noise sums.
The noise spectral density's underlying data is stored in its :attr:`~.NoiseDensityBase.spectral_density`
property.
.. code-block:: python
>>> response.spectral_density
array([1.29259971e-07, 1.00870891e-07, 8.45132667e-08, 7.57294937e-08,
7.12855936e-08, 6.91259094e-08, 6.81002020e-08, 6.76188164e-08,
6.73941734e-08, 6.72894850e-08])
.. include:: /defs.txt
Examples
========
Basic circuits
--------------
Some basic circuit example scripts are provided in the |Zero| source directory, or in the
`development repository`_. These scripts can be run directly as long as |Zero| is installed and
available.
LISO
----
.. toctree::
:maxdepth: 2
liso-input
\ No newline at end of file
liso-input
.. _development repository: https://git.ligo.org/sean-leavey/zero/tree/master/examples/native
......@@ -3,23 +3,22 @@
Input file parsing
==================
|Zero| is capable of parsing some LISO input files. First, import the LISO input parser:
.. code:: python
from zero.liso import LisoInputParser
>>> from zero.liso import LisoInputParser
then create a parser object:
|Zero| is capable of parsing :ref:`most <liso/input:Known incompatibilities>` LISO input files.
To start, create a new parser:
.. code:: python
parser = LisoInputParser()
>>> parser = LisoInputParser()
To parse a LISO circuit, either call the :meth:`~.LisoParser.parse` method with text:
.. code:: python
parser.parse("""
>>> parser.parse("""
c c1 10u gnd n1
r r1 430 n1 nm
r r2 43k nm nout
......@@ -36,27 +35,24 @@ Or point it to a file using the :code:`path` parameter:
.. code:: python
parser.parse(path="/path/to/liso/script.fil")
>>> parser.parse(path="/path/to/liso/script.fil")
Get the solution with :meth:`~.LisoParser.solution` and plot and show it with
:meth:`.Solution.plot` and :meth:`.Solution.show`:
.. code:: python
solution = parser.solution()
solution.plot()
solution.show()
>>> solution = parser.solution()
>>> solution.plot()
>>> solution.show()
.. image:: /_static/liso-input-response.svg
You can at any time list the circuit's constituent components:
.. code:: python
parser.circuit
.. code-block:: text
.. code-block:: python
>>> parser.circuit
Circuit with 6 components and 5 nodes
1. c1 [in=gnd, out=n1, C=1e-05]
......@@ -70,8 +66,8 @@ You can also plot the circuit's node network using Graphviz, if installed:
.. code:: python
from zero.display import NodeGraph
NodeGraph(parser.circuit)
>>> from zero.display import NodeGraph
>>> NodeGraph(parser.circuit)
.. image:: /_static/liso-input-node-graph.svg
......
......@@ -6,7 +6,42 @@ Solutions
>>> from zero.solution import Solution
The :class:`.Solution` class provides a mechanism for storing, displaying and saving
the output of an :ref:`analysis <analyses/index:Analyses>`.
the output of an :ref:`analysis <analyses/index:Analyses>`; these are usually
:ref:`responses <data/index:Responses>` and :ref:`noise spectral densities <data/index:Noise spectral densities>`.
Retrieving functions
--------------------
Functions can be retrieved by matching against sources, sinks, groups and (in the case of noise)
types using :meth:`.filter_responses` and :meth:`.filter_noise`. These methods return a :class:`dict`
containing the matched functions in lists keyed by their group names.
To retrieve an individual function, two convenience methods are provided: :meth:`.get_response` and
:meth:`~.Solution.get_noise`. These take as arguments the source and sink of the :class:`~.data.Response`
or :class:`~.data.NoiseDensity` to retrieve, plus the optional group name. The source and sink in
:meth:`.get_response` and the sink in :meth:`~.Solution.get_noise` can be :class:`components <.Component>`
or :class:`nodes <.Node>` or names, while the source in :meth:`~.Solution.get_noise` can be a
:class:`~.components.Noise` or noise string such as ``V(op1)``.
.. code-block:: python
>>> import numpy as np
>>> from zero import Circuit
>>> from zero.analysis import AcNoiesAnalysis
>>> circuit = Circuit()
>>> circuit.add_opamp(name="op1", model="OP27", node1="gnd", node2="nin", node3="nout")
>>> circuit.add_resistor(name="r1", value="1k", node1="nin", node2="nout")
>>> op1 = circuit["op1"]
# Perform noise analysis.
>>> noise_analysis = AcNoiseAnalysis(circuit)
>>> solution = analysis.calculate(frequencies=frequencies, input_type="voltage", node="nin", sink="nout")
# Get voltage noise from op-amp at the output node.
>>> str(solution.get_noise(op1.voltage_noise, "nout"))
'V(op1) to nout'
# Alternatively retrieve using string.
>>> str(solution.get_noise("V(op1)", "nout"))
'V(op1) to nout'
Combining solutions
-------------------
......@@ -14,7 +49,6 @@ Combining solutions
Solutions from different analyses can be combined and plotted together. The method :meth:`.Solution.combine`
takes as an argument another solution, and returns a new solution containing functions from both.
.. warning::
In order to be combined, the solutions must have identical frequency vectors, but *no* identical
......
......@@ -15,8 +15,8 @@ class SolutionTestCase(TestCase):
"""Solution tests"""
def setUp(self):
# components
op11 = OpAmp(model="OP00", node1="n11", node2="n12", node3="n13")
op21 = OpAmp(model="OP00", node1="n21", node2="n22", node3="n23")
op11 = OpAmp(name="op1", model="OP00", node1="n11", node2="n12", node3="n13")
op21 = OpAmp(name="op2", model="OP00", node1="n21", node2="n22", node3="n23")
# data points in each set
count1 = 100
count2 = 101
......@@ -87,9 +87,12 @@ class SolutionTestCase(TestCase):
self.assertTrue(sol_a.equivalent_to(sol_b))
def test_constituent_noise_sum_equal_total_noise_sum(self):
sources = [VoltageNoise(component=OpAmp(model="OP00", node1="n1", node2="n2", node3="n3")),
VoltageNoise(component=OpAmp(model="OP00", node1="n4", node2="n5", node3="n6"))]
op = OpAmp(model="OP00", node1="n1", node2="n2", node3="n3")
# Names op1 and op2 are required for meta equivalency.
sources = [VoltageNoise(component=OpAmp(name="op1", model="OP00", node1="n1", node2="n2",
node3="n3")),
VoltageNoise(component=OpAmp(name="op2", model="OP00", node1="n4", node2="n5",
node3="n6"))]
op = OpAmp(name="op2", model="OP00", node1="n1", node2="n2", node3="n3")
noise1 = self.noise11
series2 = Series(x=noise1.frequencies, y=noise1.spectral_density)
sink = noise1.sink
......@@ -98,9 +101,9 @@ class SolutionTestCase(TestCase):
noise_sum = np.sqrt(sum([noise.spectral_density ** 2 for noise in constituents]))
sum_series = Series(self.frequencies1, noise_sum)
# sum from constituents
# Sum from constituents.
noisesum1 = MultiNoiseDensity(sink=sink, constituents=constituents)
# sum from total
# Sum from total.
noisesum2 = MultiNoiseDensity(sources=sources, sink=sink, series=sum_series)
self.assertTrue(noisesum1.equivalent(noisesum2))
......@@ -197,3 +200,48 @@ class SolutionTestCase(TestCase):
self.assertCountEqual(sol_c.groups, ["Sol A", "Sol B"])
self.assertCountEqual(sol_c.functions["Sol A"], [self.response11, self.response12])
self.assertCountEqual(sol_c.functions["Sol B"], [self.response13])
def test_get_response(self):
"""Test method to retrieve individual response"""
# Default group.
sol_a = Solution(self.frequencies1)
sol_a.add_response(self.response11)
sol_a.add_response(self.response12)
self.assertEqual(sol_a.get_response(source="nso11", sink="ntfs11"), self.response11)
self.assertEqual(sol_a.get_response(source="nso11", sink="ntfs12"), self.response12)
# With group.
sol_b = Solution(self.frequencies1)
sol_b.add_response(self.response11, group="b")
sol_b.add_response(self.response12, group="b")
self.assertEqual(sol_b.get_response(source="nso11", sink="ntfs11", group="b"),
self.response11)
self.assertEqual(sol_b.get_response(source="nso11", sink="ntfs12", group="b"),
self.response12)
# Default groups shouldn't have the response.
self.assertRaises(ValueError, sol_b.get_response, source="nso11", sink="ntfs11")
self.assertRaises(ValueError, sol_b.get_response, source="nso11", sink="ntfs12")
def test_get_noise(self):
"""Test method to retrieve individual noise spectral density"""
# Default group.
sol_a = Solution(self.frequencies1)
sol_a.add_noise(self.noise11)
sol_a.add_noise(self.noise12)
self.assertEqual(sol_a.get_noise(source="V(op1)", sink="nns11"), self.noise11)
self.assertEqual(sol_a.get_noise(source="V(op1)", sink="nns12"), self.noise12)
# With group.
sol_b = Solution(self.frequencies1)
sol_b.add_noise(self.noise11, group="b")
sol_b.add_noise(self.noise12, group="b")
self.assertEqual(sol_b.get_noise(source="V(op1)", sink="nns11", group="b"), self.noise11)
self.assertEqual(sol_b.get_noise(source="V(op1)", sink="nns12", group="b"), self.noise12)
# Default groups shouldn't have the noise.
self.assertRaises(ValueError, sol_b.get_noise, source="V(op1)", sink="ntfs11")
self.assertRaises(ValueError, sol_b.get_noise, source="V(op1)", sink="ntfs12")
# Using objects.
noise11_cmp = self.noise11.source.component
self.assertEqual(sol_a.get_noise(source=noise11_cmp.voltage_noise, sink=Node("nns11")),
self.noise11)
......@@ -582,7 +582,7 @@ class Node(metaclass=NamedInstance):
class Noise(metaclass=abc.ABCMeta):
"""Noise spectral density.
"""Noise source.
Parameters
----------
......@@ -622,7 +622,7 @@ class Noise(metaclass=abc.ABCMeta):
class ComponentNoise(Noise, metaclass=abc.ABCMeta):
"""Component noise spectral density.
"""Component noise source.
Parameters
----------
......@@ -646,7 +646,7 @@ class ComponentNoise(Noise, metaclass=abc.ABCMeta):
class NodeNoise(Noise, metaclass=abc.ABCMeta):
"""Node noise spectral density.
"""Node noise source.
Parameters
----------
......@@ -673,6 +673,7 @@ class NodeNoise(Noise, metaclass=abc.ABCMeta):
class VoltageNoise(ComponentNoise):
"""Component voltage noise source."""
SUBTYPE = "voltage"
def label(self):
......@@ -680,6 +681,7 @@ class VoltageNoise(ComponentNoise):
class JohnsonNoise(VoltageNoise):
"""Resistor Johnson-Nyquist noise source."""
SUBTYPE = "johnson"
def __init__(self, resistance, *args, **kwargs):
......@@ -703,6 +705,7 @@ class JohnsonNoise(VoltageNoise):
class CurrentNoise(NodeNoise):
"""Node current noise source."""
SUBTYPE = "current"
def label(self):
......
......@@ -42,7 +42,7 @@
# Strings starting with '#' are comments.
schema: 1
distributed_with: 0.6.0
distributed_with: 0.6.4
op-amps:
......
......@@ -43,7 +43,6 @@ class Series:
def __init__(self, x, y):
if x.shape != y.shape:
raise ValueError("specified x and y vectors do not have the same shape")
self.x = x
self.y = y
......@@ -242,13 +241,17 @@ class SingleSinkFunction(Function, metaclass=abc.ABCMeta):
class Response(SingleSourceFunction, SingleSinkFunction, Function):
"""Response data series"""
@property
def complex_magnitude(self):
return self.series.y
@property
def magnitude(self):
return db(np.abs(self.series.y))
return db(np.abs(self.complex_magnitude))
@property
def phase(self):
return np.angle(self.series.y) * 180 / np.pi
return np.angle(self.complex_magnitude) * 180 / np.pi
def series_equivalent(self, other):
"""Checks if the specified function has an equivalent series to this one."""
......
......@@ -315,9 +315,9 @@ class Solution:
if groups != self.RESPONSE_GROUPS_ALL:
# Filter by group.
for group in responses:
for group in list(responses):
if group not in groups:
del responses[groups]
del responses[group]
if sources != self.RESPONSE_SOURCES_ALL:
if isinstance(sources, str):
......@@ -357,6 +357,52 @@ class Solution:
return responses
def get_response(self, source, sink, group=None):
"""Get response from specified source to specified sink.
This is a convenience method for :meth:`.filter_responses` for when only a single response
is required.
Parameters
----------
source : :class:`str` or :class:`.Node` or :class:`.Component`
The response source element.
sink : :class:`str` or :class:`.Node` or :class:`.Component`
The response sink element.
group : :class:`str`, optional
The response group. If `None`, the default group is assumed.
Raises
------
ValueError
If no response is found, or if more than one matching response is found.
Examples
--------
Get response from node `nin` to node `nout` using string specifiers:
>>> get_response("nin", "nout")
Get response from node `nin` to component `op1` using objects:
>>> get_response(Node("nin"), op1)
Get response from node `nin` to node `nout`, searching only in group `b`:
>>> get_response("nin", "nout", group="b")
"""
if group is None:
group = self.DEFAULT_GROUP_NAME
response_groups = self.filter_responses(sources=[source], sinks=[sink], groups=[group])
if not response_groups:
raise ValueError("no response found")
responses = list(response_groups.values())[0]
if not responses:
raise ValueError("no response found")
if len(response_groups) > 1 or len(responses) > 1:
raise ValueError("degenerate responses for the specified source, sink, and group")
return responses[0]
def filter_noise(self, **kwargs):
"""Filter for noise spectra.
......@@ -386,9 +432,9 @@ class Solution:
if groups != self.NOISE_GROUPS_ALL:
# Filter by group.
for group in spectra:
for group in list(spectra):
if group not in groups:
del spectra[groups]
del spectra[group]
if sources != self.NOISE_SOURCES_ALL:
if isinstance(sources, str):
......@@ -437,6 +483,55 @@ class Solution:
return spectra
def get_noise(self, source, sink, group=None):
"""Get noise spectral density from specified source to specified sink.
This is a convenience method for :meth:`.filter_noise` for when only a single noise spectral
density is required.
Parameters
----------
source : :class:`str` or :class:`~.components.Noise`
The noise source element.
sink : :class:`str` or :class:`.Node` or :class:`.Component`
The noise sink element.
group : :class:`str`, optional
The noise group. If `None`, the default group is assumed.
Raises
------
ValueError
If no noise spectral density is found, or if more than one matching noise spectral
density is found.
Examples
--------
Get noise arising from op-amp `op1`'s voltage noise at node `nout` using string specifiers:
>>> get_noise("V(op1)", "nout")
Get noise arising from op-amp `op1`'s voltage noise at component `op2` using objects:
>>> get_noise(op1.voltage_noise, op2)
Get noise arising from op-amp `op1`'s voltage noise at node `nout`, searching only in group
`b`:
>>> get_noise("V(op1)", "nout", group="b")
"""
if group is None:
group = self.DEFAULT_GROUP_NAME
noise_groups = self.filter_noise(sources=[source], sinks=[sink], groups=[group])
if not noise_groups:
raise ValueError("no noise found")
noise_densities = list(noise_groups.values())[0]
if not noise_densities:
raise ValueError("no noise found")
if len(noise_groups) > 1 or len(noise_densities) > 1:
raise ValueError("degenerate noise spectral densities for the specified source, sink, "
"and group")
return noise_densities[0]
@property
def responses(self):
return {group: [function for function in functions if isinstance(function, Response)]
......
Markdown is supported
0%