"""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