Chapter 2: Hardware Module¶
In this tutorial we will show some of the tools available in the hardware module of SeQUeNCe. The goal of this tutorial is to
Gain familiarity with the sequence
components
moduleGain familiarity with the sequence
topology.node
moduleSee how networks may be built with sequence
To achieve this, we will construct a couple simple networks and show their functionality. We will also create custom “protocols” to control and monitor hardware, as well as custom node types to hold all of our components.
Example: Optical Hardware¶
In this example, we will build a simple 2-node network. There will be a quantum memory on one node and a detector on the other node connected by a quantum channel. The topology is shown here:
Background¶
The hardware used in this tutorial is a single atom quantum memory and a single photon detector (SPD). The quantum memory exists in one of two spin states: spin up (|↑⟩) or down (|↓⟩). When an “excite” operation is applied to the memory, consisting of a short light pulse, a memory in the |↓⟩ state may emit a photon. A memory in the |↑⟩ state will emit no photon. As a quantum device, the memory may also exist in a superposition of states. One example is the |+⟩ = 1/√2(|↑⟩ + |↓⟩) state used in this example, where the memory has an equal probability of being in the up or down spin states with the same phase.
Step 1: Nodes and Hardware¶
To begin, we create our custom node classes. We will make two types - a SenderNode
to hold the memory and send photons and a ReceiverNode
to receive and detect photons. Both of our node types will inherit from Node
, the basic node class from sequence, and we invoke the parent constructor for Node
. In general, entities (such as nodes and hardware) require a string name
and a Timeline
in their constructor, as well as necessary parameters (for more details on timelines, see Tutorial 1).
We will now create our hardware. The detector is created easily, as no specific parameters are required (but we wish to set the efficiency
to 1 to prevent errors).
The required parameters for memories are more numerous and are listed here:
fidelity
: fidelity of entanglement. This is usually set to 0 when unentangled, but can be set to other values as it is usually replaced when entangled.frequency
: the frequency at which the memory can be excited. A frequency of 0 means that the memory can be excited at infinite frequency.efficiency
: the probability that the memory will emit a photon when it is supposed to. We set it to 1 here to prevent photon loss.coherence_time
: the time for which a memory state (other than down) is viable, given in seconds.wavelength
: the wavelength of emitted photons.
Next, we will add each component to the proper node using the add_component
method.
This method adds the component to the node’s components
dictionary, which maps component names to objects.
It may be accessed by outside protocols to monitor components or get their current state.
We’ll put the detector on the receiver node, and the memory on the sender node.
from sequence.kernel.timeline import Timeline
from sequence.topology.node import Node
from sequence.components.memory import Memory
from sequence.components.detector import Detector
class SenderNode(Node):
def __init__(self, name, timeline):
super().__init__(name, timeline)
memory_name = name + ".memory"
memory = Memory(memory_name, timeline, fidelity=1, frequency=0,
efficiency=1, coherence_time=0, wavelength=500)
self.add_component(memory)
memory.add_receiver(self)
def get(self, photon, **kwargs):
self.send_qubit(kwargs['dst'], photon)
class ReceiverNode(Node):
def __init__(self, name, timeline):
super().__init__(name, timeline)
detector_name = name + ".detector"
detector = Detector(detector_name, timeline, efficiency=1)
self.add_component(detector)
self.set_first_component(detector_name)
detector.owner = self
def receive_qubit(self, src, qubit):
self.components[self.first_component_name].get(qubit)
You may notice that the initialization methods make use of a few additional functions, and methods have been added to the node classes.
These are to establish the internal hardware connections on the node.
The first of these methods is the Entity.get
method.
All physical simulation elements, including optical hardware and nodes, inherit from this class.
The get
method is used to receive photons from another entity which may be further processed.
The add_receiver
method similarly designates another component (or components) to receive photons from the current entity.
In our case, we wish to set the memory’s receiver as the SenderNode
, so that the node may receive emitted photons and direct them to an inter-node quantum channel using send_qubit
.
This is performed in the SenderNode.get
method.
At the other end of the channel, the receive_qubit
method is called by a quantum channel on the receiving node of a transmission.
For this method, the src
input specifies the name of the node sending the qubit.
In our case, we don’t care about the source node, so we can ignore it.
The qubit
input is the transmitted photon; it is sent to the detector for measurement.
This is done using the first_component_name
attribute, which designates a component on a node to receive all incoming photons.
Step 2: Custom Counting Protocol¶
Next, we will create our custom protocols using custom classes.
Let’s denote the first class as Counter
, as we will be counting photon detection.
The initializing method is very simple, only setting the count to 0.
We then proceed to the trigger function, which will handle information from the detector.
Normally, the detector will pass two arguments through this function (a reference to the specific detector and info including the detection time), but we are not concerned with these.
We only wish to increment our counter.
class Counter:
def __init__(self):
self.count = 0
def trigger(self, detector, info):
self.count += 1
We then add this counter protocol to our receiver node, by modifying the initialization method.
To have the counter monitor the detector, we invoke the Entity.attach
method.
This method ensures that any updates (such as detection events) from an entity are passed to the object specified in the method arguments.
class ReceiverNode(Node):
def __init__(self, name, timeline):
super().__init__(name, timeline)
detector_name = name + ".detector"
detector = Detector(detector_name, timeline, efficiency=1)
self.add_component(detector)
self.set_first_component(detector_name)
detector.owner = self
self.counter = Counter()
detector.attach(self.counter)
The second protocol class is slightly more complicated, and will be activating the quantum memory. We will define it later in the tutorial.
Step 3: Build the Network¶
We are now ready to start writing the main function of our script. The first step is to create the simulation timeline. We will use a 10 second run time, but more or less time may be needed depending on hardware parameters. Note that the runtime is given in picoseconds.
from sequence.kernel.timeline import Timeline
tl = Timeline(10e12)
We can then create our two network nodes using our custom node class. We only need to specify a name for each node and the timeline it belongs to:
node1 = SenderNode("node1", tl)
node2 = ReceiverNode("node2", tl)
node1.set_seed(0)
node2.set_seed(1)
Note that we also set the random generator seed for our nodes to ensure reproducability. Next, we create the quantum channel to provide connectivity between the nodes. We won’t need a classical channel, as we’re not sending any messages between nodes. In the initializer, we again specify the name and timeline, and include the additional required attenuation and distance parameters. We set attenuation to 0, so that we do not lose any photons in the channel (try changing it to see the effects!), and set the distance to one kilometer (note that the distance is given in meters). The set_ends
method finally sets the sender and receiver for the channel, where the receiver is given as the name of the receiving node.
from sequence.components.optical_channel import QuantumChannel
qc = QuantumChannel("qc", tl, attenuation=0, distance=1e3)
qc.set_ends(node1, node2.name)
Step 4: Measure Memory Once¶
With the network built, we are ready to schedule simulation events and run our experiment.
The details on scheduling events are covered in Tutorial 1, so we will not focus on them here.
Let’s first run one experiment with the memory in the |↑⟩ state and observe the detection time of the single emitted photon.
We can obtain the memory object using the Node.get_components_by_type
method, which returns a list of matching components on the node.
The memory state can then be set with the update_state
method.
memories = node1.get_components_by_type("Memory")
memory = memories[0]
memory.update_state([complex(0), complex(1)])
We set the state of this single memory to a quantum state, given as a complex array of coefficients for the |↑⟩ and |↓⟩ states.
Let’s also change our counter slightly to record the detection time. This can be done by accessing the 'time'
field of the detector info:
class Counter:
def __init__(self):
self.count = 0
self.time = 0
def trigger(self, detector, info):
self.count += 1
self.time = info['time']
We must also schedule an excite event for the memory, which will send a photon to a connected node supplied as an argument (in this case, we’ll use "node2"
). Let’s put it at time 0:
from sequence.kernel.process import Process
from sequence.kernel.event import Event
process = Process(memory, "excite", ["node2"])
event = Event(0, process)
tl.schedule(event)
We can then run our single experiment. The procedure to initialize and run the timeline is the same as Tutorial 1:
tl.init()
tl.run()
We should see that the count
field of our Counter
class is now 1, and that we have a detection time greater than 0 resulting from the quantum channel delay. Quantum channel delay is calculated based on the speed of light in an optical fiber and the length of the fiber (delay = L / c). We can view the detection and detection time as follows:
print("detection count: {}".format(node2.counter.count))
print("detection time: {}".format(node2.counter.time))
Step 5: Repeated Operation¶
Next, let’s repeatedly set the memeory to the |+⟩ state and record detection events. To give us a clean state, we’ll remove the code we wrote for step 4.
The events we wish to schedule are all for the memory.
We want to first set it to a |+⟩ state with the update_state
method, and then excite the memory to measure emitted photons with the excite
method.
The update_state
method will require a plus state as input.
The excite
method needs an argument for the desired destination node, so we’ll supply the name of our node2
.
We’ll schedule both of these at a predetermined period.
To manage all of these requirements, we’ll write our second protocol class, the Sender
.
The protocol will need a reference to the local node, as well as the name of the memory to trigger.
Requiring a node and the names of local hardware is typical of protocols in SeQUeNCe.
We’ll include all memory modification in the start
method, which will activate the protocol.
import math
class Sender:
def __init__(self, own, memory_name):
self.own = own
self.memory = own.components[memory_name]
def start(self, period):
process1 = Process(self.memory, "update_state", [[complex(math.sqrt(1/2)), complex(math.sqrt(1/2))]])
process2 = Process(self.memory, "excite", ["node2"])
for i in range(NUM_TRIALS):
event1 = Event(i * period, process1)
event2 = Event(i * period + (period / 2), process2)
self.own.timeline.schedule(event1)
self.own.timeline.schedule(event2)
We’ll then place this protocol in the SenderNode
class:
class SenderNode(Node):
def __init__(self, name, timeline):
super().__init__(name, timeline)
memory_name = name + ".memory"
memory = Memory(memory_name, timeline, fidelity=1, frequency=0,
efficiency=1, coherence_time=0, wavelength=500)
self.add_component(memory)
memory.add_receiver(self)
self.sender = Sender(self, memory_name)
def get(self, photon, **kwargs):
self.send_qubit(kwargs['dst'], photon)
Step 6: Running and Output¶
The procedure to initialize and run the timeline is the same as Tutorial 1.
We will also add a call to the start
method of our protocol, using a calculated period.
We’ll use a predetermined frequency FREQUENCY
(given in Hz) for a set number of trials NUM_TRIALS
.
tl.init()
period = int(1e12 / FREQUENCY)
node1.sender.start(period)
tl.run()
To access the results of our simulation, we just need the count parameter of our custom counter class. We’ll read it, and present the number of detections we had as a percent of the number of excite operations:
print("percent measured: {}%".format(100 * node2.counter.count / NUM_TRIALS))
We expect the percent to be about 50%, as we initialized the memory in the |+⟩ state each time. Try messing with parameters to achieve different measurement results!
Example: Classical Messaging¶
In this example, we will build a simple 2-node network connected with a two-way classical channel. The topology is shown here:
We’ll send a PING
message from node 1 at time 0, and when we recieve it at node 2, we’ll send back a PONG
. For both cases we will print out the reception and the time at which we receive the message.
Step 1: Defining Message and Protocols¶
For this example, we won’t need to add any functions or hardware to the network nodes, so we will use the base Node
class from SeQUeNCe. We will, however, need to define protocols for our nodes to control and send messages. They will achieve this through the send_message
and receive_message
methods of Node
.
Our protocols will need a custom message type to work. In sequence, message types are given as native python enums. We construct the message type as follows:
from enum import Enum, auto
class MsgType(Enum):
PING = auto()
PONG = auto()
Now we can define the protocols. They will inherit from the Protocol
class in SeQUeNCe. The PingProtocol
on node 1 will send an initial PING
message with the start
method, and the PongProtocol
will send a PONG
message in response. Let’s view their implementations and go over the methods required:
from sequence.topology.node import Node
from sequence.protocol import Protocol
from sequence.message import Message
class PingProtocol(Protocol):
def __init__(self, own: Node, name: str, other_name: str, other_node: str):
super().__init__(own, name)
own.protocols.append(self)
self.other_name = other_name
self.other_node = other_node
def init(self):
pass
def start(self):
new_msg = Message(MsgType.PING, self.other_name)
self.own.send_message(self.other_node, new_msg)
def received_message(self, src: str, message: Message):
assert message.msg_type == MsgType.PONG
print("node {} received pong message at time {}".format(self.own.name, self.own.timeline.now()))
class PongProtocol(Protocol):
def __init__(self, own: Node, name: str, other_name: str, other_node: str):
super().__init__(own, name)
own.protocols.append(self)
self.other_name = other_name
self.other_node = other_node
def init(self):
pass
def received_message(self, src: str, message: Message):
assert message.msg_type == MsgType.PING
print("node {} received ping message at time {}".format(self.own.name, self.own.timeline.now()))
new_msg = Message(MsgType.PONG, self.other_name)
self.own.send_message(self.other_node, new_msg)
In both cases, the constructor requires
The node the instance is attached to
own
,The name of the protocol instance
name
,The name of the other (paired) protocol instance
other_name
, andThe name of the other node hosting the paired protocol
other_node
.
The name and node are required by the constructor of the base Protocol
class, to attach the protocol to a node and provide a unique identifier. We will use the other name and other node to send messages. We must also add the protocol to the node’s protocol list here.
We also must add an init
method. This is required of all protocols, and is called when the timeline init
method is evoked, but we do not need to perform any actions here.
Next is the start
method of the PingProtocol. We wish to send a message to the other protocol with this method. First, we create a message with the desired message type PING
. This also specifies the destination protocol (other_name
). Next, we invoke the send_message
method of the node to which we are currently attached. This method requires the message to send as well as the name of the destination node (other_node
).
We will next define the received_message
method of both protocols. This method is called by the host node (from the constructor) when a message is received for the protocol instance. Its arguments include
src
, the name of the source node, andmessage
, theMessage
object delivered by the classical channel.
On both protocols, we wish to display when we receive a message. We can see the name of the node receiving the message as well as the time at which it was received (in picoseconds). For the PongProtocol
, we additionally send a PONG
message back to the starting node.
Step 2: Building the Network¶
We have now already completed the majority of the work required for our experiment! The only thing left is to create our nodes, protocols, and classical channel connection, and then run the experiment (which we will do in the next step). Classical channels in sequence are one-way only, so we will need to define two to achieve our two-way communication.
from sequence.kernel.timeline import Timeline
from sequence.components.optical_channel import ClassicalChannel
tl = Timeline(1e12)
node1 = Node("node1", tl)
node2 = Node("node2", tl)
cc0 = ClassicalChannel("cc0", tl, 1e3, 1e9)
cc1 = ClassicalChannel("cc1", tl, 1e3, 1e9)
cc0.set_ends(node1, node2.name)
cc1.set_ends(node2, node1.name)
pingp = PingProtocol(node1, "pingp", "pongp", "node2")
pongp = PongProtocol(node2, "pongp", "pingp", "node1")
The classical channel constructor takes a name and timeline followed by the distance
(in meters) and delay
of the channel (in picoseconds). Here we set the distance to 1 km and the delay to 1 ms. The set_ends
method is identical to that for the quantum channel.
Step 3: Scheduling and Running.¶
We finally schedule the start of our ping-pong communication and run the experiment:
from sequence.kernel.process import Process
from sequence.kernel.event import Event
process = Process(pingp, "start", [])
event = Event(0, process)
tl.schedule(event)
tl.init()
tl.run()
In the output, we see that the PING
message is received on node 2 first, followed by a PONG
message received on node 1. The reception times of the messages are at 1 ms and 2 ms of simulation time, respectively, as determined by our classical channel delay.