Source code for finesse.components.general

"""Top-level objects which specific optical, and otherwise, components should inherit
from."""

from copy import copy
from collections import OrderedDict
import enum
import logging
import numbers

import numpy as np

from finesse.components.node import NodeType, Port
from finesse.exceptions import ComponentNotConnected, NoCouplingError
from finesse.utilities import check_name, is_iterable
from finesse.element import ModelElement
from finesse.utilities.misc import DeprecationHelper


LOGGER = logging.getLogger(__name__)


# IMPORTANT: renaming this class impacts the katscript spec and should be avoided!
[docs]class Variable(ModelElement): """The variable element acts slightly different to other elements. When added to a model it creates a new :class:`finesse.parameter.Parameter` in the model it has been added to. This does the same as calling :function:`finesse.model.Model.add_parameter`. This new parameter can be used like a variable for making symbolic links to or for storing some useful number about the model. See :function:`finesse.model.Model.add_parameter` for more details. """ def __init__( self, name: str, value, description: str = None, units: str = "", is_geometric: bool = False, changeable_during_simulation: bool = True, ): super().__init__(name) self.value = value self.description = description self.units = units self.is_geometric = is_geometric self.changeable_during_simulation = changeable_during_simulation
[docs]class LocalDegreeOfFreedom: """A local degree of freedom definition that combines a DC parameter and AC nodes at some element. For example, this can pair a mirror tuning and it the AC mechanical nodes into one "Degree of Freedom" that can be referenced to scan, drive, or readout. Some DOFs do not have a DC equivalent so the DC part may be `None`. A DOF can have a different input (drive) and output (readout) signal node. This is used in more advanced cases such as suspension systems, where you drive some motion through a force/torque actuation on some part of the suspension but the readout is in displacement/rotation of the final optic. Parameters ---------- name : str Name should be the full-name of the definition for a particular element, e.g. `m1.dofs.z` if this is wrong, then unparsing will not work correctly DC : Parameter, optional The DC equivlent of the AC signal node of an element, setting to `None` means no DC actuation happens. AC_IN : SignalNode The node that is driven for this degree of freedom, cannot be None. DC_2_AC_scaling : float, optional Scaling factor relating the DC and AC parameter and nodes. For example, the scaling between phi (degrees) and `mirror.mech.z` (meters). AC_OUT : SignalNode, optional The node that is read out to describe this degree of freedom, if `None` there is nothing to readout here. """ def __init__(self, name, DC=None, AC_IN=None, DC_2_AC_scaling=None, AC_OUT=None): self.name = name self.DC = DC self.AC_IN = AC_IN self.DC_2_AC_scaling = DC_2_AC_scaling if AC_OUT is None: self.AC_OUT = AC_IN else: self.AC_OUT = AC_OUT # if self.AC_IN.type != self.AC_OUT.type: # raise FinesseException( # f"Nodes {self.AC_IN} and {self.AC_OUT} must be of the same type: {self.AC_IN.type}!={self.AC_OUT.type}" # ) @property def AC_IN_type(self): if self.AC_IN: return self.AC_IN.type else: return None @property def AC_OUT_type(self): if self.AC_OUT: return self.AC_OUT.type else: return None def __repr__(self): return f"❮'{self.name}' @ {hex(id(self))} ({self.__class__.__name__}) DC={self.DC} AC_IN={self.AC_IN} AC_OUT={self.AC_OUT}❯"
DOFDefinition = DeprecationHelper( "DOFDefinition", "finesse.components.general.LocalDegreeOfFreedom", LocalDegreeOfFreedom, "3.b0", )
[docs]def unique_element(): """Flags that this element type is unique in a model. In other words, only one of these element types can be in a single model. """ def func(cls): cls._unique_element = True return cls return func
[docs]def borrows_nodes(): """Flags that a ModelElement will be making references to nodes owner by other elements, or borrows a reference.""" def func(cls): cls._borrows_nodes = True return cls return func
[docs]@enum.unique class CouplingType(enum.Enum): """An enum describing the type of coupling between two nodes.""" OPTICAL_TO_OPTICAL = 0 OPTICAL_TO_ELECTRICAL = 1 OPTICAL_TO_MECHANICAL = 2 ELECTRICAL_TO_ELECTRICAL = 3 ELECTRICAL_TO_OPTICAL = 4 ELETRICAL_TO_MECHANICAL = 5 MECHANICAL_TO_MECHANICAL = 6 MECHANICAL_TO_OPTICAL = 7 MECHANICAL_TO_ELECTRICAL = 8
[docs]@enum.unique class NoiseType(enum.Enum): """An enum describing the type of noise a component generates.""" QUANTUM = 0
[docs]def determine_coupling_type(from_node, to_node): """Retrieves the type of coupling (see :class:`.CouplingType`) between two nodes. Parameters ---------- from_node : :class:`.Node` Node which couples into `to_node`. to_node : :class:`.Node` Node which has a coupling from `from_node`. Returns ------- coupling_t : :class:`.CouplingType` The type of coupling between the two given nodes. """ convert = { (NodeType.OPTICAL, NodeType.OPTICAL): CouplingType.OPTICAL_TO_OPTICAL, (NodeType.OPTICAL, NodeType.ELECTRICAL): CouplingType.OPTICAL_TO_ELECTRICAL, (NodeType.OPTICAL, NodeType.MECHANICAL): CouplingType.OPTICAL_TO_MECHANICAL, ( NodeType.ELECTRICAL, NodeType.ELECTRICAL, ): CouplingType.ELECTRICAL_TO_ELECTRICAL, (NodeType.ELECTRICAL, NodeType.OPTICAL): CouplingType.ELECTRICAL_TO_OPTICAL, ( NodeType.ELECTRICAL, NodeType.MECHANICAL, ): CouplingType.ELETRICAL_TO_MECHANICAL, ( NodeType.MECHANICAL, NodeType.MECHANICAL, ): CouplingType.MECHANICAL_TO_MECHANICAL, (NodeType.MECHANICAL, NodeType.OPTICAL): CouplingType.MECHANICAL_TO_OPTICAL, ( NodeType.MECHANICAL, NodeType.ELECTRICAL, ): CouplingType.MECHANICAL_TO_ELECTRICAL, } return convert[(from_node.type, to_node.type)]
[docs]@enum.unique class InteractionType(enum.Enum): """An enum describing the type of interaction between two nodes.""" REFLECTION = 0 TRANSMISSION = 1
[docs]class FrequencyGenerator: """The base class for components which generate optical frequencies. A component inheriting from this class will allow the model to query the component to ask what frequencies it wants to use. Frequency generation comes in the form of either a laser or something that modulates. """ def _couples_frequency(self, ws, connection, frequency_in, frequency_out): """This method returns whether this element is coupling frequencies for a particular connection. For example, a modulator would modulator frequencies when a field passes through it. Or a suspended mirror would couple the upper and lower sidebands on reflection due to radiation pressure effects. Parameters ---------- ws : :class:`.ElementWorkspace` Workspace for this particular component connection : str Name of the connection being queried frequency_in : :class:`.Frequency` Input frequency frequency_out : :class:`.Frequency` Output frequency Returns ------- bool True if frequencies couple at this element """ return False def _modulation_frequencies(self): return [] def _source_frequencies(self): return [] def _on_response(self, freqWeakRef): """Callback function that model calls to say whether requested frequency was granted or not. If so a weak reference to the frequency object is returned. Parameters ---------- freqWeakRef: Weakref.ref Weak reference to the assigned Frequency object Raises ------ Exception If passed a name to a frequency this element did not ask for originally. """ # if freqWeakRef() in self.__requested_frequencies: self.__frequencies.append(freqWeakRef)
# else: # raise Exception(f"{self} never requested a frequency {freqWeakRef()}")
[docs]class NoiseGenerator: """The base class for components which generate some kind of noise. A component inheriting from this class will allow the model to query the component to ask what noise it generates. """ def __init__(self): self.__noises = {} def _register_noise_output( self, name, node, noise_type, ): if noise_type not in self.__noises.keys(): self.__noises[noise_type] = [] self.__noises[noise_type].append((name, node)) def _couples_noise(self, ws, node, noise_type, frequency_in, frequency_out): """This method returns whether the noise sidebands this element produces are covariant for the specified frequencies at this node. Parameters ---------- ws : :class:`.ElementWorkspace` Workspace for this particular component node : :class:`.Node` Node to check. noise_type : :class:`.NoiseType` NoiseType to check. frequency_in : :class:`.Frequency` Input frequency frequency_out : :class:`.Frequency` Output frequency Returns ------- bool True if the specified sidebands are covariant. """ return frequency_in.index == frequency_out.index @property def noises(self): return self.__noises
# IMPORTANT: renaming this class impacts the katscript spec and should be avoided!
[docs]class Connector(ModelElement): """Base class for any component which connects nodes together. Internally it stores the nodes and the connections associated with the component. During the matrix build this class will then ensure that the matrix elements for each coupling requested are allocated and and the required matrix view for editing their values is retrieved. The inheriting class should call ``_register_node`` and ``_register_coupling`` to define the connections it wants to use. Parameters ---------- name : str Name of the new `Connector` instance. """ _borrows_nodes = False
[docs] def __init__(self, name): super().__init__(name) self.__connections = OrderedDict() self.__enabled_checks = OrderedDict() self.__ports = OrderedDict() self.__interaction_types = {} self.__nodes = OrderedDict() self._abcd_matrices = {} self.__abcds_symbolised = False
def __nodes_of(self, *node_types): return tuple([node for node in self.nodes.values() if node.type in node_types]) @property def borrows_nodes(self): """Whether this element borrows node references from another. When this is True the element may not create all of its own nodes and just link into one that already exists and is owned by another element. """ return self._borrows_nodes @property def optical_nodes(self): """The optical nodes stored by the connector. :getter: Returns a list of the stored optical nodes (read-only). """ return self.__nodes_of(NodeType.OPTICAL) @property def signal_nodes(self): """The signal nodes stored by the connector. :getter: Returns a list of the stored signal nodes (read-only). """ return self.__nodes_of(NodeType.ELECTRICAL, NodeType.MECHANICAL) @property def ports(self): """Retrieves the ports available at the object. Returns ------- tuple Read-only tuple of the ports available at this object. """ return tuple(self.__ports.values()) def _add_port(self, name, type): """Creates either an electrical, mechanical, or optical port for this component. Each port can then have multiple different nodes associated with - each node being an equation to solve for in the linear system. For example, the input and output optical field at one part of a component would be one port, i.e. reflection from one side of a mirror. The port is added to the component object directly to be referenced at a later time when the users is connecting components or definining couplings in a component. Parameters ---------- name : str Name of the port type : NodeType Type of nodes this port holds Returns ------- Port object added """ check_name(name) if name in self.ports: raise Exception("Port %s already exists for this object" % name) if hasattr(self, name): raise Exception( "Port name %s already exists as an attribute in of this object" % name ) p = Port(name, self, type) self.__ports[name] = p # self._unfreeze() assert not hasattr(self, name) setattr(self, name, p) # self._freeze() return p @property def _enabled_checks(self): return copy(self.__enabled_checks) @property def _registered_connections(self): return copy(self.__connections) @property def all_internal_optical_connections(self): """A dictionary of all the optical connections this element is making between its nodes.""" return { k: (self.nodes[v[0]], self.nodes[v[1]]) for k, v in self.__connections.items() if ( self.coupling_type(self.nodes[v[0]], self.nodes[v[1]]) == CouplingType.OPTICAL_TO_OPTICAL ) } @property def all_internal_connections(self): """A dictionary of all the connections this element is making between its nodes.""" return { k: (self.nodes[v[0]], self.nodes[v[1]]) for k, v in self.__connections.items() }
[docs] def coupling_type(self, from_node, to_node): """Obtains the type of coupling (see :class:`.CouplingType`) between the two specified nodes at this component. Parameters ---------- from_node : :class:`.Node` Node which has a forwards coupling to `to_node`. to_node : :class:`.Node` Node which has a backwards coupling from `from_node`. Returns ------- coupling_t : :class:`.CouplingType` The type of coupling between the specified nodes. """ try: return determine_coupling_type(from_node, to_node) except KeyError: return None
[docs] def interaction_type(self, from_node, to_node): """Obtains the type of interaction (see :class:`.InteractionType`) between the two specified nodes at this component. Parameters ---------- from_node : :class:`.Node` Node which has a forwards coupling to `to_node`. to_node : :class:`.Node` Node which has a backwards coupling from `from_node`. Returns ------- interaction_t : :class:`.InteractionType` The type of interaction between the specified nodes. """ try: return self.__interaction_types[(from_node.full_name, to_node.full_name)] except KeyError: return None
@property def nodes(self): """All the nodes of all the ports at this component. Order is likely to be the order in which the ports and nodes were created, but this is not guaranteed. Returns ------- nodes : tuple(:class:`.Node`) Copy of nodes dictionary """ return copy(self.__nodes) def _register_node_coupling( self, connection_id: str, from_node, to_node, interaction_type=None, forced_name=None, enabled_check=None, ): """Registers that this element will connect the output of one of its nodes to the input of another. Each element is responsible for requesting connections to be made within a Model. In practice this means the model will allocate the required matrix elements for this element to fill in. Parameters ---------- connection_id : str A unique string ID for this element which is used to identify this connection. from_node : :class:`.Node` The input node to_node : :class:`.Node` The output node interaction_type : :class:`.InteractionType`, optional The type of interaction between the nodes if applicable. forced_name : str, optional Can force the name of a connection. Used by spaces/wires for using the correct name as it doesn't own the node enabled_check : function, optional A function that returns a True/False if this connection should be enabled when a simulation is built from a model. The default value None means True. """ from finesse.components import Space, Wire if forced_name is None: name = "{}->{}".format(from_node.full_name, to_node.full_name) else: name = forced_name try: if self._model is not None: raise Exception("Component has already been added to a model") except ComponentNotConnected: pass if name in self.__connections: raise Exception( "Connection called {} already set at component {}".format( self.name, name ) ) # TODO: decide whether we actually need this - shouldn't be possible # to connect non-existing ports without python complaining about # an undefined parameter first anyway if isinstance(self, Space) or isinstance(self, Wire): if from_node.port not in from_node.port.component.ports: raise Exception() if to_node.port not in to_node.port.component.ports: raise Exception() else: if from_node.full_name not in self.nodes: raise Exception( "Node {}.{} is not available at component `{}`".format( from_node.port.name, from_node.name, self.name ) ) if to_node.full_name not in self.nodes: raise Exception( "Node {}.{} is not available at component `{}`".format( to_node.port.name, to_node.name, self.name ) ) if (from_node.full_name, to_node.full_name) in self.__connections.values(): raise Exception( f"Connection between {(from_node.full_name, to_node.full_name)} already exists" ) self.__connections[connection_id] = (from_node.full_name, to_node.full_name) if enabled_check: self.__enabled_checks[connection_id] = enabled_check if interaction_type is not None: self.__interaction_types[ (from_node.full_name, to_node.full_name) ] = interaction_type
[docs] def is_valid_coupling(self, from_node, to_node): """Flags whether the provided node coupling exists at this connector.""" return (from_node.full_name, to_node.full_name) in self.__connections.values()
[docs] def check_coupling(self, from_node, to_node): """Checks that a coupling exists between `from_node` -> `to_node` and raises a ``ValueError`` if not.""" fname, tname = from_node.full_name, to_node.full_name if (fname, tname) not in self.__connections.values(): raise NoCouplingError(f"No coupling exists between {fname} -> {tname}")
def _parse_from_to_nodes(self, from_node, to_node): if isinstance(from_node, numbers.Integral): from_node = getattr(self, f"p{from_node}") if isinstance(to_node, numbers.Integral): to_node = getattr(self, f"p{to_node}") if isinstance(from_node, Port): from_node = from_node.i if isinstance(to_node, Port): to_node = to_node.o return from_node, to_node def _resymbolise_ABCDs(self): # By default components will not have to resymbolise optical ABCD matrices pass
[docs] def register_abcd_matrix(self, M_sym, *couplings): """Register an ABCD matrix of the given symbolic form for a sequence of coupling(s). Specifying several couplings for one `M_sym` means that all these node couplings will point to the same reference ABCDs --- i.e. the matrices kept in the underlying ABCD matrix store will be the same blocks of memory. .. warning:: This should only be used in the ``_resymbolise_ABCDs`` method of Connectors, when implementing a new component. Parameters ---------- M_sym : :class:`numpy.ndarray` A 2x2 matrix of symbolic elements describing the analytic form of the ABCD matrix for the given coupling(s). couplings : sequence of tuples Arguments of tuples giving the node couplings which are described by the given symbolic ABCD matrix `M_sym`. These tuples can be of size two or three, with the first two elements always as the `from_node` -> `to_node` instances. The former case implies that both the tangential and sagittal plane ABCD matrix couplings are equal and so both directions 'x' and 'y' in the underlying matrices store will be set to the same values. Whilst the latter case, where the third element is either 'x' or 'y', sets just these direction keys to this matrix. """ M_num = np.array(M_sym, dtype=np.float64) for coupling in couplings: if not is_iterable(coupling) or len(coupling) < 2 or len(coupling) > 3: raise ValueError( f"Expected coupling {coupling} passed to _register_abcd_matrix " "to be an iterable of length two (from_node, to_node) or " "length three (from_node, to_node, direction)." ) # No plane given, so same matrix will be used for # both planes at this given node coupling if len(coupling) == 2: from_node, to_node = coupling direction = ("x", "y") # Otherwise use the specified plane else: from_node, to_node, direction = coupling if direction != "x" and direction != "y": raise ValueError( f"Expected direction argument of coupling {coupling} to " f"be either 'x' or 'y' but got {direction}." ) direction = (direction,) # Check that the node coupling actually exists on this connector self.check_coupling(from_node, to_node) for d in direction: key = (from_node, to_node, d) if key in self._abcd_matrices: raise ValueError( f"There is already an ABCD matrix defined for the coupling " f"{from_node.full_name} -> {to_node.full_name} in the " f"plane '{d}'!" ) LOGGER.debug( "For node coupling %s -> %s, in plane %s, registered " "ABCD matrix:\n Symbolic: %s, Current Numeric: %s", from_node.full_name, to_node.full_name, d, M_sym.tolist(), M_num.tolist(), ) self._abcd_matrices[(from_node, to_node, d)] = M_sym, M_num
def _re_eval_abcds(self): for M_sym, M_num in self._abcd_matrices.values(): M_num[:] = np.array(M_sym, dtype=np.float64)
[docs] def ABCD( self, from_node, to_node, direction="x", symbolic=False, copy=True, retboth=False, allow_reverse=False, ): """ Parameters ---------- from_node : :class:`.OpticalNode` or :class:`.Port` or str or int Input node. If a port, or string repr of a port, is given then the *input* optical node of that port will be used. to_node : :class:`.OpticalNode` or :class:`.Port` or str or int Output node. If a port, or string repr of a port, is given then the *output* optical node of that port will be used. direction : str, optional; default: 'x' Direction of ABCD matrix computation, default is 'x' for tangential plane. symbolic : bool, optional; default: False Whether to return the symbolic matrix (as given by equations above). Defaults to False such that the numeric matrix is returned. copy : bool, optional; default: True Whether to return a copy of ABCD matrix (or matrices if `retboth` is true). Defaults to True so that the internal matrix cannot be accidentally altered. Use caution if switching this flag off. retboth : bool, optional; default: False Whether to return both the symbolic and numeric matrices as a tuple in that order. allow_reverse : bool, optional When True, if the coupling does not exist at the component from_node->to_node but to_node->from_node does exist, it will return the ABCD from that. Otherwise a NoCouplingError will be raised. Returns ------- M : :class:`numpy.ndarray` The ABCD matrix of the specified coupling for the mirror. This is symbolic if either of `symbolic` or `retboth` flags are True. M2 : :class:`numpy.ndarray` Only returned if `retboth` is True, otherwise just `M` above is returned. This will always be the numeric matrix. Raises ------ err : :class:`ValueError` If no coupling exists between `from_node` and `to_node`. """ from_node, to_node = self._parse_from_to_nodes(from_node, to_node) try: self.check_coupling(from_node, to_node) except NoCouplingError: if allow_reverse: self.check_coupling(to_node.opposite, from_node.opposite) else: raise if not self.__abcds_symbolised: self._resymbolise_ABCDs() self.__abcds_symbolised = True key = (from_node, to_node, direction) if key not in self._abcd_matrices: # If an optical coupling exists and no abcd has been specfied # then we can assume it is an identity transformation M_sym = np.eye(2, dtype=object) M_num = np.eye(2) else: M_sym, M_num = self._abcd_matrices[key] if M_sym is None: # Symbolic M is None if an error occurred during the tracing # In such a case M_num is a TotalReflectionError or # some other exception instance raise M_num # Evaluate M_sym and assign to memory of M_num so that # M_num always corresponds to current parameter state M_num[:] = np.array(M_sym, dtype=np.float64) if copy: Ms = M_sym.copy() Mn = M_num.copy() else: Ms = M_sym Mn = M_num if retboth: return Ms, Mn if symbolic: return Ms return Mn