<jupyter_start><jupyter_text>TutorialThis notebook is the tutorial and user guide for the algorithm implemented in https://github.com/BoxiLi/repeater-cut-off-optimization.<jupyter_code>import numpy as np
import matplotlib.pyplot as plt

from repeater_algorithm import plot_algorithm, RepeaterChainSimulation<jupyter_output><empty_output><jupyter_text>Entanglement swapIn the following example, we simulate a swap protocol of three nodes, where the middle one is a repeater.![swap-3.png](attachment:swap-3.png)First, we define the following parameters for the simulation:- `protocol`: Asymmetric protocol is represented by a tuple of 0 and 1, where 0 stands for swap and 1 stands for distillation. In this example we have only one entanglement swapping, hence it is a single-element tuple `(0,)`;- `p_gen`: The success probability of direct entanglement generation between two adjacent nodes;- `p_swap`: The success probability of entanglement swapping;- `w0`: The initial Werner parameter of the state. Since we use the Werner state as representation, the Werner parameter fully determines the two-qubit quantum state.- `t_coh`: The coherence time of the entangled qubits in quantum memories. The Werner parameter decreases exponentially according to this time scale. The time unit is the time used in one attempt of direct entanglement generation.- `t_trunc`: The truncation time of the simulation. It should be chosen large enough so that the almost all probability distribution (>99%) is included.<jupyter_code>parameters = {
    "protocol": (0,),
    "p_gen": 0.01,
    "p_swap": 0.5,
    "w0": 0.8,  # fidelity 0.85
    "t_coh": 1500,
    "t_trunc": 3000,
}
# Initialize simulator
simulator = RepeaterChainSimulation()
# compute the waiting time distribution and the time-dependent Werner parameter
pmf, w_func = simulator.nested_protocol(parameters)<jupyter_output><empty_output><jupyter_text>Time distributionWe can plot the distribution of obtaining the end-to-end entanglement:<jupyter_code>plt.plot(pmf)
plt.xlabel("time")
plt.ylabel("probability")
plt.show()<jupyter_output><empty_output><jupyter_text>The plot above shows the waiting time distribution of this three-node swap protocol. In the first few time steps, the probability is very low because the two direct entangled pairs are note prepared. The distribution peaks around 200.We can compute the probability mass that is covered by this distribution<jupyter_code>np.sum(pmf)<jupyter_output><empty_output><jupyter_text>We can also calculate the average waiting time and compare it with the analytical result.<jupyter_code># numerical
from utility_functions import get_mean_waiting_time
numerical_result = get_mean_waiting_time(pmf)
# analytical
p_gen = parameters["p_gen"]
p_swap = parameters["p_swap"]
analytical_result = (3-2*p_gen)/((2-p_gen)*p_gen*p_swap)

print("Numerical result:", numerical_result)
print("Analytical result:", analytical_result)<jupyter_output>Numerical result: 299.49795473146975
Analytical result: 299.4974874371859<jupyter_text>FidelitySame as the waiting time, we cal also plot the quality of the entanglement as a function of time. Here, we use `werner_to_fid` and `fid_to_werner` to transfer between the Werner parameter and the fidelity. Notice that the first few steps might be inaccurate because their probability is so small that leads to numerical instability.<jupyter_code>from utility_functions import werner_to_fid
w_func[:1] = np.nan  # remove instable part
plt.plot(werner_to_fid(w_func))
plt.xlabel("time")
plt.ylabel("fidelity")
plt.show()<jupyter_output><empty_output><jupyter_text>We see from the above figure that, due to memory decay, the later the entanglement is produced, the lower is its fidelity. Since we neglect other noise in the quantum operation, we can in principle obtain a perfect entanglement at time step 1, although the probability is extremely low. We compute the average fidelity:<jupyter_code>from utility_functions import get_mean_werner, werner_to_fid
werner_to_fid(get_mean_werner(pmf, w_func))<jupyter_output><empty_output><jupyter_text>Entanglemen distillation/purificationEntanglement distillation is used to improve the fidelity of entangled qubits e.g. by merging two entangled pairs followed by postselection.![distillation.png](attachment:distillation.png)Here we use the [BBPSSW](https://arxiv.org/abs/quant-ph/9511027) distillation protocol. Distillation is represented by 1 in our protocol. We add a distillation on top of the swap protocol we've plotted above. The whole protocol now has 3 nodes. Two pairs of qubits will be first entangled by swapping in parallel. After they are both prepared, the distillation is executed.<jupyter_code>parameters["protocol"] = (1,)
pmf, w_func = simulator.nested_protocol(parameters)
print("Average waiting time", get_mean_waiting_time(pmf))
print("Average fidelity", werner_to_fid(get_mean_werner(pmf, w_func)))<jupyter_output>Average waiting time 187.16387187812782
Average fidelity 0.8672694353330785<jupyter_text>Cut-offCut-off set a limit on the entanglement distribution process. For instance, usually, one entangled pair is usually generated and stored in the quantum memory, waiting for the other pair to be prepared. The fidelity decays during the storage because of noise. Sometimes, it is beneficial to set a limit on the storage time. If the storage time exceeds the threshold. The process is terminated and restarts from entanglement generation between adjacent nodes.Here we use the 3-node swap protocol again, but now with a cut-off. Because cut-off is only effective for small coherence time. We modify the parameters for this comparison.We can use `list` in parameters and `create_iter_ikwargs` to iterate over different cutoff values.<jupyter_code>parameters = {
    "protocol": (0,),
    "p_gen": 0.01,
    "p_swap": 0.5,
    "w0": 0.98,  # fidelity 0.95
    "t_coh": 300,
    "t_trunc": 3000,
    "cut_type": "memory_time",
    "cutoff": [1000000, 50],
}

from logging_utilities import create_iter_kwargs
parameters_list = create_iter_kwargs(parameters)
print("First parameter set")
print(parameters_list[0])
print("Second parameter set")
print(parameters_list[1])

pmf1, w_func1 = simulator.nested_protocol(parameters_list[0])
pmf2, w_func2 = simulator.nested_protocol(parameters_list[1])
tlist = np.arange(parameters["t_trunc"])
plt.plot(tlist, pmf1, tlist, pmf2)
plt.legend(("without cutoff", "with cutoff"))
plt.xlabel("Time")
plt.ylabel("Probability")
plt.show()
print("Average waiting time without cutoff", get_mean_waiting_time(pmf1))
print("Average waiting time with cutoff", get_mean_waiting_time(pmf2))

w_func1[:1] = np.nan
w_func2[:1] = np.nan
plt.plot(tlist, werner_to_fid(w_func1), tlist, werner_to_fid(w_func2))
plt.xlabel("Time")
plt.ylabel("Fidelity")
plt.show()
print("Average fidelity without cutoff", werner_to_fid(get_mean_werner(pmf1, w_func1)))
print("Average fidelity with cutoff", werner_to_fid(get_mean_werner(pmf2, w_func2)))<jupyter_output><empty_output><jupyter_text>High-level symmetric protocolsThe code offers a simple way to define nested symmetric protocols. In these protocols, the two entangled pairs used in swap or distillation at higher levels are produced by the same protocol used to generate current-level entanglement. For instance, a protocol with 9 nodes and 8 segments that executes 3 rounds of entanglement swapping can be defined by<jupyter_code>parameters = {
    "protocol": (0, 0, 0,),
    "p_gen": 0.01,
    "p_swap": 0.5,
    "w0": 1.,
    "t_coh": 8000,
    "mt_cut": 800,
    "t_trunc": 15000,
}

simulator = RepeaterChainSimulation()
pmf, w_func = simulator.nested_protocol(parameters)
print("average waiting time:", get_mean_waiting_time(pmf))
plt.plot(pmf)
plt.xlabel("time")
plt.ylabel("probability")
plt.show()<jupyter_output>average waiting time: 3072.4120380656077<jupyter_text>Another example includes dsitillation<jupyter_code>parameters = {
    "protocol": (0, 1, 1, 0,),
    "p_gen": 0.01,
    "p_swap": 0.5,
    "w0": 0.98,
    "t_coh": 3000,
    "mt_cut": 800,
    "t_trunc": 15000,
}

simulator = RepeaterChainSimulation()
pmf, w_func = simulator.nested_protocol(parameters)
print("average waiting time:", get_mean_waiting_time(pmf))
plt.plot(pmf)
plt.xlabel("time")
plt.ylabel("probability")
plt.show()
w_func[:100] = np.nan
plt.plot(w_func)<jupyter_output>average waiting time: 2536.6808962710084<jupyter_text>Optimization of cut-offsThe optimization using differential evolution algorithm is implemented in the class `CutoffOptimizer`.<jupyter_code>from optimize_cutoff import CutoffOptimizer
parameters = {
    "protocol": (0,),
    "p_gen": 0.01,
    "p_swap": 0.5,
    "w0": 0.98,  # fidelity 0.95
    "t_coh": 300,
    "t_trunc": 3000,
    "cut_type": "memory_time",
}

# We use the logging system to show the information
import logging
from logging_utilities import log_init
log_init("cutoff_optimze", level=logging.INFO)

optimizer = CutoffOptimizer(adaptive=True)
optimizer.run(parameters)<jupyter_output>INFO:root:ID: cutoff_optimze-20200906-195533
INFO:root:Date: 2020-09-06 19:55:33

INFO:root:-------------------------------------------
INFO:root:Optimization of the cut-off time

INFO:root:Parameters:
protocol (0,)
p_gen 0.01
p_swap 0.5
w0 0.98
t_coh 300
t_trunc 3000
cut_type memory_time

INFO:root:Pretraining begins...
INFO:root:Pretraining finishes, reference pmf obtained.<jupyter_text>The optimal cutoff is `cutoff=64`.We now perform an optimization on a 3-level swapping protocol.<jupyter_code>from optimize_cutoff import CutoffOptimizer
parameters = {
    "protocol": (0, 0, 0),
    "p_gen": 0.01,
    "p_swap": 0.5,
    "w0": 0.98,  # fidelity 0.95
    "t_coh": 4000,
    "t_trunc": 30000,
    "cut_type": "memory_time",
}
optimizer = CutoffOptimizer(adaptive=True)
optimizer.run(parameters)<jupyter_output>INFO:root:-------------------------------------------
INFO:root:Optimization of the cut-off time

INFO:root:Parameters:
protocol (0, 0, 0)
p_gen 0.01
p_swap 0.5
w0 0.98
t_coh 4000
t_trunc 30000
cut_type memory_time

INFO:root:Pretraining begins...
INFO:root:Pretraining finishes, reference pmf obtained.<jupyter_text>The best cutoff time on each level is different.One can also force all the cutoff time to be the same.<jupyter_code>optimizer = CutoffOptimizer(adaptive=True, opt_kind="uniform_de")
optimizer.run(parameters)<jupyter_output>INFO:root:-------------------------------------------
INFO:root:Optimization of the cut-off time

INFO:root:Parameters:
protocol (0, 0, 0)
p_gen 0.01
p_swap 0.5
w0 0.98
t_coh 4000
t_trunc 30000
cut_type memory_time

INFO:root:Pretraining begins...
INFO:root:Pretraining finishes, reference pmf obtained.
{'fraction_non_alphanumeric': 0.10470466710489248, 'fraction_numerical': 0.036435346041881866, 'mean_word_length': 5.5297363580625385, 'pattern_counts': {'":': 42, '<': 50, '<?xml version=': 0, '>': 51, 'https://': 2, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '14', 'dir': 'jupyter-structured-clean-dedup', 'id': '312539', 'license': 'Apache-2.0', 'n_tokens_mistral': 3736, 'n_tokens_neox': 3263, 'n_words': 1073, 'path': 'tutorial.ipynb', 'repo_name': 'BoxiLi/repeater-cut-off-optimization', 'size': '10649'}