Source code for finesse.components.space

"""Space-type objects representing physical distances between components."""

import logging
import types
import finesse
import numpy as np

from finesse.symbols import Variable, Matrix
from finesse.env import warn
from finesse.parameter import float_parameter
from finesse.exceptions import ContextualTypeError, ContextualValueError
from finesse.components.general import (
    Connector,
    InteractionType,
    borrows_nodes,
    LocalDegreeOfFreedom,
)
from finesse.components.workspace import ConnectionSetting
from finesse.components.node import NodeType, Port, NodeDirection
from finesse.tracing import abcd

LOGGER = logging.getLogger(__name__)


[docs]@borrows_nodes() @float_parameter( "L", "Length", validate="_check_L", units="m", is_geometric=True, ) @float_parameter( "nr", "Refractive index", validate="_check_nr", is_geometric=True, changeable_during_simulation=False, ) @float_parameter("user_gouy_x", "Gouy phase (x)", units="Degrees") @float_parameter("user_gouy_y", "Gouy phase (y)", units="Degrees") # IMPORTANT: renaming this class impacts the katscript spec and should be avoided! class Space(Connector): """Represents a space between two components in the interferometer configuration, with a given length and index of refraction. There can be many spaces in a model which are not of much interest and do not need to be referred to. For example, the ``link`` command will create spaces between components without giving an explicit name, just specifying a distance between them. All Space elements are added to the Model's ``.spaces`` namespace so they can all easily be iterated over. You can also find spaces which had no name specified and thus have an autogenerated name. User specified Space's with names will be put in the main Model namespace so that they can be accessed like any other element in the model. Parameters ---------- name : str, optional Name of newly created space. If not specified, a name is automatically generated. portA, portB : :class:`.Port` Ports to connect. L : float, optional Geometric length of newly created :class:`.Space` instance; defaults to 0. nr : float, optional Index of refraction of newly created :class:`.Space` instance; defaults to 1.0. user_gouy_x, user_gouy_y : float, optional User-defined gouy phase to override the calculated value. """ def __new__(cls, name, portA, portB, *args, **kwargs): # Override None name with auto https://gitlab.com/ifosim/finesse/finesse3/-/merge_requests/251 if (portA is None) != (portB is None): warn( "Can't construct a space with only one port connected; ignoring ports." ) portA = None portB = None if portA is not None and not isinstance(portA, Port): raise ContextualTypeError("portA", portA, allowed_types=(Port,)) if portA is not None and not isinstance(portB, Port): raise ContextualTypeError("portB", portB, allowed_types=(Port,)) if portA is not None and portA.type != NodeType.OPTICAL: raise ContextualValueError({"portA": portA}, "must be an optical port") if portB is not None and portB.type != NodeType.OPTICAL: raise ContextualValueError({"portB": portB}, "must be an optical port") if portA is not None and portB is not None: if portA.component._model is not portB.component._model: raise ValueError("Port A and B are not part of the same model") if name is None: auto_generated = True if portA is not None and portB is not None: compA = portA.component.name compB = portB.component.name name = f"{compA}_{portA.name}__{compB}_{portB.name}" else: raise ValueError( "Cannot create an unconnected space without providing a name" ) instance = super(Space, cls).__new__( cls, name, portA, portB, *args, **kwargs ) instance._auto_generated = auto_generated instance._auto_generated_name = name else: instance = super(Space, cls).__new__( cls, name, portA, portB, *args, **kwargs ) instance._auto_generated = False instance._auto_generated_name = None return instance
[docs] def __init__( self, name, portA, portB, L=0.0, nr=1.0, user_gouy_x=None, user_gouy_y=None, ): if self._auto_generated: super().__init__(self._auto_generated_name) self._namespace = (".spaces",) else: super().__init__(name) # Also put into main namespace if it has a specific name self._namespace = (".", ".spaces") self.__portA = portA self.__portB = portB self._add_to_model_namespace = True self.L = L self.nr = nr self.user_gouy_x = user_gouy_x self.user_gouy_y = user_gouy_y self._add_port("p1", NodeType.OPTICAL) self._add_port("p2", NodeType.OPTICAL) # Phase modulation input self._add_port("phs", NodeType.ELECTRICAL) self.phs._add_node("i", NodeDirection.INPUT) # Amplitude modulation input self._add_port("amp", NodeType.ELECTRICAL) self.amp._add_node("i", NodeDirection.INPUT) # strain input self._add_port("h", NodeType.ELECTRICAL) self.h._add_node("i", NodeDirection.INPUT) if portA is not None and portB is not None: self.connect(portA, portB) # Define typical degrees of freedom for this component self.dofs = types.SimpleNamespace() # Strain doesn't have a DC term really in Finesse # changing a space length won't generate a signal self.dofs.h = LocalDegreeOfFreedom(f"{self.name}.dofs.h", None, self.h.i, 1)
[docs] def optical_equations(self): """Calculates the optical equations for the space component. Returns ------- dict: A dictionary containing the optical equations for the space component. The keys match the optical couplings defined by the Space, and the values represent the corresponding complex coupling equations. """ with finesse.symbols.simplification(): settings = self._model._settings _f_ = Variable("_f_") _c0_ = finesse.symbols.CONSTANTS["c0"] pi = finesse.symbols.CONSTANTS["pi"] gouy = Matrix(f"{self.name}.gouy") pre_factor = 2 * pi * self.nr.ref * self.L.ref / _c0_ phi = pre_factor * _f_ if settings.is_modal: return { f"{self.name}.P1i_P2o": np.exp(-1j * phi) * gouy, f"{self.name}.P2i_P1o": np.exp(-1j * phi) * gouy, } else: # Otherwise we just get the phase from the propagation along the # space if the frequency is different to that of the model reference # wavelength return { f"{self.name}.P1i_P2o": np.exp(-1j * phi), f"{self.name}.P2i_P1o": np.exp(-1j * phi), }
@property def gouy_x(self): # return user value if it is set # otherwise try to return calculated value or default if self.user_gouy_x.value is not None: return self.user_gouy_x.value else: try: return self.gouy(self.portA.o.qx, self.portB.i.qx) except RuntimeError: # No simulation, use the default value return 0.0 @property def gouy_y(self): # return user value if it is set # otherwise try to return calculated value or default if self.user_gouy_y.value is not None: return self.user_gouy_y.value else: try: return self.gouy(self.portA.o.qy, self.portB.i.qy) except RuntimeError: # No simulation, use the default value return 0.0
[docs] def gouy(self, q1, q2): """Computes the Gouy phase in degrees from beam parameters. Parameters ---------- q1 : complex, :class:`.BeamParam` Starting beam parameter q2 : complex, :class:`.BeamParam` Ending beam parameter Returns ------- out : float Gouy phase (in degrees) """ return np.degrees( abs(np.arctan2(q1.real, q1.imag) - np.arctan2(q2.real, q2.imag)) )
@property def portA(self): return self.__portA @property def portB(self): return self.__portB
[docs] def connect(self, portA, portB): """Sets the ports of this `Space`. Parameters ---------- portA : :class:`.Port`, optional Port to connect portB : :class:`.Port`, optional Port to connect """ if portA.is_connected: raise Exception(f"Port {portA} has already been connected to") if portB.is_connected: raise Exception(f"Port {portB} has already been connected to") # From the Space's perspective the input and output # nodes are swapped around for its ports self.p1._add_node("i", None, portA.o) self.p1._add_node("o", None, portA.i) self.p2._add_node("i", None, portB.o) self.p2._add_node("o", None, portB.i) self._register_node_coupling( "P1i_P2o", self.p1.i, self.p2.o, interaction_type=InteractionType.TRANSMISSION, ) self._register_node_coupling( "P2i_P1o", self.p2.i, self.p1.o, interaction_type=InteractionType.TRANSMISSION, ) self._register_node_coupling("SIGPHS_P1o", self.phs.i, self.p1.o) self._register_node_coupling("SIGPHS_P2o", self.phs.i, self.p2.o) self._register_node_coupling("SIGAMP_P1o", self.amp.i, self.p1.o) self._register_node_coupling("SIGAMP_P2o", self.amp.i, self.p2.o) self._register_node_coupling("H_P1o", self.h.i, self.p1.o) self._register_node_coupling("H_P2o", self.h.i, self.p2.o)
def _get_workspace(self, sim): from finesse.components.modal.space import ( space_carrier_fill, space_signal_fill, space_set_gouy, SpaceWorkspace, ) _, is_changing = self._eval_parameters() carrier_refill = sim.carrier.any_frequencies_changing and ( float(self.L.value) > 0 or self.L.is_changing ) carrier_refill |= len(is_changing) carrier_refill |= self in sim.trace_forest carrier_refill |= self.user_gouy_x.is_changing carrier_refill |= self.user_gouy_y.is_changing ws = SpaceWorkspace(self, sim) # use user gouy if it is defined ws.use_user_gouy_x = self.user_gouy_x.value is not None ws.use_user_gouy_y = self.user_gouy_y.value is not None ws.set_gouy_function(space_set_gouy) # Set the fill function for this simulation ws.carrier.add_fill_function(space_carrier_fill, carrier_refill) ws.carrier.connection_settings["P1i_P2o"] = ConnectionSetting.DIAGONAL ws.carrier.connection_settings["P2i_P1o"] = ConnectionSetting.DIAGONAL if sim.signal: signal_refill = sim.signal.any_frequencies_changing and ( # only non-zero spaces which aren't changing need to be added # if signal frequency is changing float(self.L.value) > 0 or self.L.is_changing ) signal_refill |= self in sim.trace_forest signal_refill |= self.user_gouy_x.is_changing signal_refill |= self.user_gouy_y.is_changing ws.signal.add_fill_function(space_signal_fill, signal_refill) ws.signal.connection_settings["P1i_P2o"] = ConnectionSetting.DIAGONAL ws.signal.connection_settings["P2i_P1o"] = ConnectionSetting.DIAGONAL ws.signal.connection_settings["SIGPHS_P1o"] = ConnectionSetting.DIAGONAL ws.signal.connection_settings["SIGPHS_P2o"] = ConnectionSetting.DIAGONAL ws.signal.connection_settings["SIGAMP_P1o"] = ConnectionSetting.DIAGONAL ws.signal.connection_settings["SIGAMP_P2o"] = ConnectionSetting.DIAGONAL ws.signal.connection_settings["H_P1o"] = ConnectionSetting.DIAGONAL ws.signal.connection_settings["H_P2o"] = ConnectionSetting.DIAGONAL # Initialise the ABCD matrix memory-views if sim.is_modal: ws.abcd = self.ABCD(self.p1.i, self.p2.o, "x", copy=False) return ws def _check_L(self, value): if value < 0: raise ValueError("Length of a space must not be negative.") return value def _check_nr(self, value): if value < 1: raise ValueError("Index of refraction must be >= 1") return value def _resymbolise_ABCDs(self): L = self.L.ref nr = self.nr.ref M_sym = abcd.space(L, nr) # Matrices same in both propagations and both planes so # only need one register call for all couplings here self.register_abcd_matrix( M_sym, (self.p1.i, self.p2.o), (self.p2.i, self.p1.o), ) @property def abcd(self): """Numeric ABCD matrix. Equivalent to any of ``space.ABCD(1, 2, "x")``, ``space.ABCD(2, 1, "x")``, ``space.ABCD(1, 2, "y")``, ``space.ABCD(2, 1, "y")``. :`getter`: Returns a copy of the (numeric) ABCD matrix (read-only). """ return self.ABCD(1, 2, "x")
[docs] def ABCD( self, from_node, to_node, direction="x", symbolic=False, copy=True, retboth=False, allow_reverse=False, ): r"""Returns the ABCD matrix of the space for the specified coupling. .. _fig_abcd_space_transmission: .. figure:: ../images/abcd_spacet.* :align: center This is given by, .. math:: M = \begin{pmatrix} 1 & \frac{L}{n_r} \\ 0 & 1 \end{pmatrix}, where :math:`L` is the length of the space and :math:`n_r` is the index of refraction. See :meth:`.Connector.ABCD` for descriptions of parameters, return values and possible exceptions. """ return super().ABCD( from_node, to_node, direction, symbolic, copy, retboth, allow_reverse )