Source code for finesse.components.readout

"""A components sub-module containing classes for detecting intensity fluctuations at a
physical point in a model.

These Readout components essentially describe baseband and broadband detectors such as
DC and RF demodulated photodiodes typically used in optical experiments.
"""

from __future__ import annotations

import abc
from collections import defaultdict
import types
import warnings

import numpy as np

import finesse
from finesse.components.general import Connector, borrows_nodes
from finesse.components.node import Node, NodeDirection, NodeType, Port
from finesse.components.workspace import ConnectorWorkspace
from finesse.detectors import pdtypes
from finesse.detectors.compute.quantum import QShot0Workspace, QShotNWorkspace
from finesse.element import ModelElement
from finesse.parameter import float_parameter
from finesse.frequency import Frequency
from finesse.simulations.sparse.simulation import SparseMatrixSimulation


from finesse.detectors.workspace import DetectorWorkspace


[docs] class ReadoutWorkspace(ConnectorWorkspace): pass
# TODO this seenms to be only used in the OptimiseRFReadoutPhaseDC action # It really does not make sense to me, since '_Readout' is already a 'ModelElement' # and it doesn't hold any information that is not present in the Readouts themself?? # IMPORTANT: renaming this class impacts the katscript spec and should be avoided!
[docs] class ReadoutDetectorOutput(ModelElement): """A placeholder element that represents a detector output generated by a Readout element. Notes ----- These should not be created directly by a user. It is internally created and added by a Readout component. """ def __init__(self, name: str, readout: _Readout): super().__init__(name) self.__readout = readout @property def readout(self): return self.__readout
[docs] class ReadoutOutputs(abc.ABC): @abc.abstractmethod def __iter__(self): pass @abc.abstractmethod def _get_output_workspaces( self, sim: SparseMatrixSimulation ) -> dict[ReadoutDetectorOutput, DetectorWorkspace]: pass
[docs] class ReadoutDCOutputs(ReadoutOutputs): def __init__(self, readout: ReadoutDC) -> types.NoneType: self.DC = ReadoutDetectorOutput(f"{readout.name}_DC", readout) self.shot_noise = ReadoutDetectorOutput(f"{readout.name}_shot_noise", readout) def __iter__(self): if hasattr(self, "shot_noise"): return iter((self.DC, self.shot_noise)) else: return iter((self.DC,)) def _get_output_workspaces( self, sim: SparseMatrixSimulation ) -> dict[ReadoutDetectorOutput, DetectorWorkspace]: from finesse.detectors import PowerDetector, QuantumShotNoiseDetector from finesse.detectors.compute.power import PD0Workspace from finesse.detectors.workspace import OutputInformation wss = {} # Setup a DC output photodiode detector for # using for outputs oinfo = OutputInformation( self.DC.name, PowerDetector, (self.DC.readout.p1.i,), np.float64, "W", None, "W", True, False, ) wss[self.DC] = PD0Workspace( self.DC.readout, sim, oinfo=oinfo, pdtype=self.DC.readout.pdtype ) if sim.signal: oinfo = OutputInformation( self.shot_noise.name, QuantumShotNoiseDetector, (self.shot_noise.readout.p1.i,), np.float64, "W/rtHz", None, "ASD", True, False, ) wss[self.shot_noise] = QShot0Workspace( self.shot_noise.readout, sim, False, output_info=oinfo ) return wss
[docs] class ReadoutRFOutputs(ReadoutOutputs): def __init__(self, readout: ReadoutRF) -> types.NoneType: self.I = ReadoutDetectorOutput(f"{readout.name}_I", readout) self.Q = ReadoutDetectorOutput(f"{readout.name}_Q", readout) self.DC = ReadoutDetectorOutput(f"{readout.name}_DC", readout) self.shot_noise = ReadoutDetectorOutput(f"{readout.name}_shot_noise", readout) def __iter__(self): if hasattr(self, "shot_noise"): return iter((self.I, self.Q, self.DC, self.shot_noise)) else: return iter((self.I, self.Q, self.DC)) def _get_output_workspaces( self, sim: SparseMatrixSimulation ) -> dict[ReadoutDetectorOutput, DetectorWorkspace]: from finesse.detectors import ( PowerDetector, PowerDetectorDemod1, QuantumShotNoiseDetectorDemod1, ) from finesse.detectors.compute.power import PD0Workspace, PD1Workspace from finesse.detectors.workspace import OutputInformation wss = {} for output in (self.I, self.Q): # Setup a single demodulation photodiode detector for # using for outputs oinfo = OutputInformation( output.name, PowerDetectorDemod1, (output.readout.p1.i,), np.float64, "W", None, "W", True, False, ) poff = 90 if output is self.Q else 0 ws = PD1Workspace( output.readout, sim, output.readout.f, output.readout.phase, phase_offset=poff, oinfo=oinfo, pdtype=output.readout.pdtype, ) wss[output] = ws # Setup a DC output photodiode detector for # using for outputs oinfo = OutputInformation( self.DC.name, PowerDetector, (self.DC.readout.p1.i,), np.float64, "W", None, "W", True, False, ) ws = PD0Workspace(self.DC.readout, sim, oinfo=oinfo) wss[self.DC] = ws if sim.signal: oinfo = OutputInformation( self.shot_noise.name, QuantumShotNoiseDetectorDemod1, (self.shot_noise.readout.p1.i,), np.float64, "W/rtHz", None, "ASD", True, False, ) ws = QShotNWorkspace( self.shot_noise.readout, sim, [ (self.shot_noise.readout.f, self.shot_noise.readout.phase), ], False, output_info=oinfo, ) wss[self.shot_noise] = ws return wss
# IMPORTANT: renaming this class impacts the katscript spec and should be avoided! class _Readout(Connector, abc.ABC): """Abstract class that provides basic functionality similar to all Readouts. Underscore because users should not be accessing it directly. Parameters ---------- name : str Name of the readout component optical_node : Node | Port | None, optional Optical node this readout should look at. Because the readout borrows the node, it can be connected anywhere in the model, like a detector. When passing `None`, the readout should be connected explicitly with a :class:`finesse.components.space.Space` or with :meth:`finesse.model.Model.link` command (and then only a single readout can be connected per port). pdtype : str | dict | None, optional A name of a pdtype definition or a dict representing a pdtype definition. See :ref:`segmented_photodiodes` output_detectors : bool, optional Whether to add relevant detectors to the solution object, by default False Raises ------ TypeError When ``optical_node`` is not a ``Port`` or a ``Node`` object, or the ``NodeType`` is not ``NodeType.Optical`` """ outputs: ReadoutOutputs def __init__( self, name: str, optical_node: Node | Port | None = None, pdtype=None, output_detectors: bool = False, ): super().__init__(name) self.pdtype = pdtype self.__output_detectors = output_detectors self._add_port("p1", NodeType.OPTICAL) if optical_node is not None: if isinstance(optical_node, Port) and optical_node.type is NodeType.OPTICAL: port = optical_node optical_node = port.nodes[0] # TODO turn this into exception and see whether this code part is actually used? warnings.warn( f"Did not specify optical node for '{self.name}', selecting " f"'{optical_node.full_name}' automatically", stacklevel=1, ) elif ( isinstance(optical_node, Node) and optical_node.type is NodeType.OPTICAL ): port = optical_node.port else: raise TypeError( f"{self.name} expects 'optical_node' " f"to be a {Port.__name__} or a {Node} of type {NodeType.OPTICAL}," f"not {optical_node}" ) other_node = tuple(o for o in port.nodes if o is not optical_node)[0] self.p1._add_node("i", None, optical_node) self.p1._add_node("o", None, other_node) else: self.p1._add_node("i", NodeDirection.INPUT) self.p1._add_node("o", NodeDirection.OUTPUT) def _on_add(self, model): if model is not self.p1._model: raise Exception( f"{repr(self)} is using a node {self.node} from a different model" ) def _on_remove(self): for output in self.outputs: self._model.remove(output) @abc.abstractmethod def _get_output_workspaces( self, sim: SparseMatrixSimulation ) -> dict[ReadoutDetectorOutput, DetectorWorkspace]: pass @property def optical_node(self) -> Node | None: if self.p1.i.component != self: return self.p1.i @property def has_mask(self): return False @property def output_detectors(self): return self.__output_detectors @output_detectors.setter def output_detectors(self, value: bool): self.__output_detectors = value def check_connection(self) -> None: if self.optical_node is not None: raise ValueError( "Don't connect spaces to Readouts already attached to " "an optical node. Connect to " f"{self.optical_node.port} directly instead." )
[docs] @borrows_nodes() # IMPORTANT: renaming this class impacts the katscript spec and should be avoided! class ReadoutDC(_Readout): """A Readout component which represents a photodiode measuring the intensity of some incident field. Audio band intensity signals present in the incident optical field are converted into an electrical signal and output at the ``self.DC`` port, which has a single ``self.DC.o`` node. See :ref:`readouts` for more information. Parameters ---------- name : str Name of the readout component optical_node : Node | Port | None, optional Optical node this readout should look at. Because the readout borrows the node, it can be connected anywhere in the model, like a detector. When passing `None`, the readout should be connected explicitly with a :class:`finesse.components.space.Space` or with :meth:`finesse.model.Model.link` command (and then only a single readout can be connected per port). pdtype : str | dict | None, optional A name of a pdtype definition or a dict representing a pdtype definition, by default None. See :ref:`segmented_photodiodes` output_detectors : bool, optional Whether to add a ``PowerDetector`` and a ``QuantumShotNoiseDetector`` to the solution object, by default False Raises ------ TypeError When ``optical_node`` is not a ``Port`` or a ``Node`` object, or the ``NodeType`` is not ``NodeType.Optical`` """ outputs: ReadoutDCOutputs def __init__( self, name: str, optical_node: Node | Port | None = None, pdtype: str | dict | None = None, output_detectors: bool = False, ): super().__init__( name, optical_node, pdtype=pdtype, output_detectors=output_detectors ) self.pdtype = pdtypes.get_pdtype(pdtype) self._add_port("DC", NodeType.ELECTRICAL) self.DC._add_node("o", NodeDirection.OUTPUT) self._register_node_coupling("P1i_DC", self.p1.i, self.DC.o) self.outputs = ReadoutDCOutputs(self) def _on_add(self, model): super()._on_add(model) model.add(self.outputs.DC) model.add(self.outputs.shot_noise) def _get_workspace(self, sim): if sim.signal: has_DC_node = self.DC.o.full_name in sim.signal.nodes if not has_DC_node: return None # Don't do anything if no nodes included ws = ReadoutWorkspace(self, sim) ws.prev_carrier_solve_num = -1 ws.I = np.eye(sim.model_settings.num_HOMs, dtype=np.complex128) ws.signal.add_fill_function(self._fill_matrix, True) ws.frequencies = sim.signal.signal_frequencies[self.DC.o].frequencies ws.is_segmented = self.pdtype is not None if ws.is_segmented: ws.K = pdtypes.construct_segment_beat_matrix( sim.model.mode_index_map, self.pdtype # , sparse_output=True ) return ws else: return None def _get_output_workspaces( self, sim ) -> dict[ReadoutDetectorOutput, DetectorWorkspace]: return self.outputs._get_output_workspaces(sim) def _fill_matrix(self, ws): """Computing E.conj() * upper + E * lower.conj()""" # if the previous fill was done with this carrier then there # is no need to refill it... if ws.prev_carrier_solve_num == ws.sim.carrier.num_solves: return for freq in ws.sim.signal.optical_frequencies.frequencies: # Get the carrier HOMs for this frequency cidx = freq.audio_carrier_index Ec = np.conjugate(ws.sim.carrier.node_field_vector(self.p1.i, cidx)) for efreq in ws.frequencies: if ws.signal.connections.P1i_DC_idx > -1: with ws.sim.signal.component_edge_fill3( ws.owner_id, ws.signal.connections.P1i_DC_idx, freq.index, efreq.index, ) as mat: if ws.is_segmented: mat[:] = np.dot(ws.K, Ec) else: mat[:] = Ec # store what carrier solve number this fill was done with ws.prev_carrier_solve_num = ws.sim.carrier.num_solves
[docs] @borrows_nodes() @float_parameter("f", "Frequency") @float_parameter("phase", "Phase") # IMPORTANT: renaming this class impacts the katscript spec and should be avoided! class ReadoutRF(_Readout): """A readout component which represents a demodulated photodiode. The ``self.I`` and ``self.Q`` electrical ports contain the signal of the two quadratures. See :ref:`readouts` for more information. Parameters ---------- name : str Name of the readout component optical_node : Node | Port | None, optional Optical node this readout should look at. Because the readout borrows the node, it can be connected anywhere in the model, like a detector. When passing `None`, the readout should be connected explicitly with a :class:`finesse.components.space.Space` or with :meth:`finesse.model.Model.link` command (and then only a single readout can be connected per port). f : float, optional Demodulation frequency in Hz, by default 0 phase : float, optional Demodulation phase in degrees, by default 0 output_detectors : bool, optional Whether to add a ``PowerDetector`` (DC), two ``PowerDetectorDemod1`` 's (I and Q) and a ``QuantumShotNoiseDetectorDemod1`` to the solution object, by default False pdtype : str | dict | None, optional A name of a pdtype definition or a dict representing a pdtype definition, by default None. See :ref:`segmented_photodiodes` """ outputs: ReadoutRFOutputs def __init__( self, name: str, optical_node: Node | Port | None = None, *, f: float = 0, phase: float = 0, output_detectors: bool = False, pdtype: str | dict | None = None, ): super().__init__( name, optical_node, pdtype=pdtype, output_detectors=output_detectors ) self.f = f self.phase = phase self._add_port("I", NodeType.ELECTRICAL) self.I._add_node("o", NodeDirection.OUTPUT) self._add_port("Q", NodeType.ELECTRICAL) self.Q._add_node("o", NodeDirection.OUTPUT) self._register_node_coupling("P1i_I", self.p1.i, self.I.o) self._register_node_coupling("P1i_Q", self.p1.i, self.Q.o) self.outputs = ReadoutRFOutputs(self) def _on_add(self, model): super()._on_add(model) model.add(self.outputs.DC) model.add(self.outputs.I) model.add(self.outputs.Q) model.add(self.outputs.shot_noise) def _get_workspace(self, sim): if sim.signal: has_I_node = self.I.o.full_name in sim.signal.nodes has_Q_node = self.Q.o.full_name in sim.signal.nodes if not (has_I_node or has_Q_node): return None # Don't do anything if no nodes included ws = ReadoutWorkspace(self, sim) ws.prev_carrier_solve_num = -1 ws.signal.add_fill_function(self._fill_matrix, True) ws.frequencies = sim.signal.signal_frequencies[ self.I.o if has_I_node else self.Q.o ].frequencies ws.dc_node_id = sim.carrier.node_id(self.p1.i) ws.is_segmented = self.pdtype is not None if ws.is_segmented: ws.K = pdtypes.construct_segment_beat_matrix( sim.model.mode_index_map, self.pdtype # , sparse_output=True ) return ws else: return None def _get_output_workspaces( self, sim ) -> dict[ReadoutDetectorOutput, DetectorWorkspace]: return self.outputs._get_output_workspaces(sim) def _fill_matrix(self, ws: ReadoutWorkspace): if ws.prev_carrier_solve_num == ws.sim.carrier.num_solves: return couplings = self._calculate_couplings(ws=ws) for matrix_key, coupling in couplings.items(): if ws.is_segmented: coupling = np.dot(ws.K, coupling) with ws.sim.signal.component_edge_fill3( *matrix_key, 0, ) as mat: mat[:] = coupling # store previous carrier solve number this fill was done with # so we don't have to repeat it ws.prev_carrier_solve_num = ws.sim.carrier.num_solves def _calculate_couplings( self, ws: ReadoutWorkspace ) -> dict[tuple[int, int, int], list[np.ndarray]]: def default_factory(): return np.zeros(ws.sim.carrier.nhoms, dtype=np.complex128) couplings = defaultdict(default_factory) # extra factor of two we do not apply here as we work # directly with amplitudes from the matrix solution # need one half gain from demod. Other factor of two from # signal scaling and 0.5 from second demod cancel out factorI = ( 0.5 * ws.sim.model_settings.EPSILON0_C * np.exp(-1j * ws.values.phase * finesse.constants.DEG2RAD) ) factorQ = ( 0.5 * ws.sim.model_settings.EPSILON0_C * np.exp(-1j * (ws.values.phase + 90) * finesse.constants.DEG2RAD) ) factorIc = factorI.conjugate() factorQc = factorQ.conjugate() # representing 4.12 from 10.1007/s41114-016-0002-8 for f1 in ws.sim.carrier.optical_frequencies.frequencies: for f2 in ws.sim.carrier.optical_frequencies.frequencies: df = f1.f - f2.f # Get the carrier HOMs for this frequency E1 = ws.sim.carrier.node_field_vector(self.p1.i, f1.index) E1c = np.conjugate(E1) E2 = ws.sim.carrier.node_field_vector(self.p1.i, f2.index) E2c = np.conjugate(E2) # get the keys to the HOMSolver._submatrices dictionary # to be passed to HOMSolver.component_edge_fill3 keys = self._get_submatrix_keys(ws, f1, f2) if df == -ws.values.f: if ws.signal.connections.P1i_I_idx >= 0: couplings[keys["P1i_I_f2_audio_lower"]] += factorI * E1c couplings[keys["P1i_I_f1_audio_upper"]] += factorIc * E2c if ws.signal.connections.P1i_Q_idx >= 0: couplings[keys["P1i_Q_f2_audio_lower"]] += factorQ * E1c couplings[keys["P1i_Q_f1_audio_upper"]] += factorQc * E2c elif df == ws.values.f: if ws.signal.connections.P1i_I_idx >= 0: couplings[keys["P1i_I_f2_audio_lower"]] += factorIc * E1c couplings[keys["P1i_I_f1_audio_upper"]] += factorI * E2c if ws.signal.connections.P1i_Q_idx >= 0: couplings[keys["P1i_Q_f2_audio_lower"]] += factorQc * E1c couplings[keys["P1i_Q_f1_audio_upper"]] += factorQ * E2c return couplings def _get_submatrix_keys( self, ws: ReadoutWorkspace, f1: Frequency, f2: Frequency ) -> dict[str, tuple[int, int, int]]: submatrix_keys = {} f2_lower = ws.sim.carrier.optical_frequencies.get_info(f2.index)[ "audio_lower_index" ] f1_upper = ws.sim.carrier.optical_frequencies.get_info(f1.index)[ "audio_upper_index" ] P1i_I_idx = ws.signal.connections.P1i_I_idx P1i_Q_idx = ws.signal.connections.P1i_Q_idx submatrix_keys["P1i_I_f2_audio_lower"] = (ws.owner_id, P1i_I_idx, f2_lower) submatrix_keys["P1i_I_f1_audio_upper"] = (ws.owner_id, P1i_I_idx, f1_upper) submatrix_keys["P1i_Q_f2_audio_lower"] = (ws.owner_id, P1i_Q_idx, f2_lower) submatrix_keys["P1i_Q_f1_audio_upper"] = (ws.owner_id, P1i_Q_idx, f1_upper) return submatrix_keys