# import the quantum circuit and the quantum register
# from the IBM's Qiskit library
from qiskit import QuantumCircuit, QuantumRegister

# import the Sparse Pauli operator from
# the IBM's Qiskit quantum information module
from qiskit.quantum_info import SparsePauliOp


# defome the function to generate a Sparse Pauli operator
# for the Heisenberg's Hamiltonian
def heisenberg_hamiltonian(
    length: int, jx: float = 1.0, jy: float = 0.0, jz: float = 0.0
) -> SparsePauliOp:
  
    # create an empty list for the terms
    terms = []
    
    
    # for each index ranged for the number of qubits on
    # the Heisenberg's Hamiltonian
    for i in range( ( length - 1 ) ):
        
        # if it is pretended to append a XX operator term to
        # the Heisenberg's Hamiltonian operator
        if jx:
            
            # append a XX operator term to
            # the Heisenberg's Hamiltonian operator
            terms.append( ( "XX", [i, i + 1], jx ) )
        
        # if it is pretended to append a YY operator term to
        # the Heisenberg's Hamiltonian operator
        if jy:
            
            # append a YY operator term to
            # the Heisenberg's Hamiltonian operator
            terms.append( ( "YY", [i, i + 1], jy ) )
    
        # if it is pretended to append a ZZ operator term to
        # the Heisenberg's Hamiltonian operator
        if jz:
            
            # append a ZZ operator term to
            # the Heisenberg's Hamiltonian operator
            terms.append( ( "ZZ", [i, i + 1], jz ) )
    
    
    # return the Sparse Pauli operator
    # for the Heisenberg's Hamiltonian
    return SparsePauliOp.from_sparse_list(terms, num_qubits=length)


# define a function to generate the quantum circuit for the state preparation
def state_prep_circuit( num_qubits: int, layers: int = 1 ) -> QuantumCircuit:
 
    # create a quantum register with the number of qubits pretended
    qubits = QuantumRegister(num_qubits, name="q")

    # create a quantum circuit with the quantum register defined before
    circuit = QuantumCircuit(qubits)
    
    # apply a Hadamard gate to
    # the quantum circuit, on all the qubits
    circuit.h(qubits)
    
    
    # for each layer of the quantum circuit
    for _ in range(layers):
    
        # for each 2n-th qubit
        for i in range(0, ( num_qubits - 1 ), 2):
            
            # apply a CX/CNOT gate to
            # the quantum circuit,
            # with control on the current qubit
            # and target on the next qubit
            circuit.cx( qubits[i], qubits[ ( i + 1 ) ] )
        
        # apply a RY (Rotation-Y) gate to
        # the quantum circuit, on the 1st qubit,
        # with the theta angle equal to 0.1
        # on all the qubits
        circuit.ry( 0.1, qubits )
    
        # for each (2n + 1)-th qubit
        for i in range(1, ( num_qubits - 1 ), 2):
            
            # apply a CX/CNOT gate to
            # the quantum circuit,
            # with control on the current qubit
            # and target on the next qubit
            circuit.cx( qubits[i], qubits[ ( i + 1 ) ] )
        
        # apply a RY (Rotation-Y) gate to
        # the quantum circuit, on the 1st qubit,
        # with the theta angle equal to 0.1
        # on all the qubits
        circuit.ry(0.1, qubits)
    
    
    # return the quantum circuit for the state preparation
    return circuit

# define the number of qubits for
# the Heisenberg's Hamiltonian operator and
# for the state preparation quantum circuit
length = 5


# create the Heisenberg's Hamiltonian operator
# for the number of qubits defined before
hamiltonian = heisenberg_hamiltonian( length, 1.0, 1.0 )

# create the state preparation quantum circuit
# for the number of qubits defined before
circuit = state_prep_circuit( length, layers=2 )


# print the Heisenberg's Hamiltonian operator
print(hamiltonian)

# draw the quantum circuit implementing
# a state preparation
circuit.draw("mpl")

# import estimator from IBM's Qiskit
# Aer primitives module
from qiskit_aer.primitives import Estimator


# create an Estimator object with approximation
estimator = Estimator( approximation=True )


# create a job for the quantum circuit implementing
# the Heisenberg's Hamiltonian, running the Estimator
job = estimator.run( circuit, hamiltonian, shots=None )

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()

# retrieve the exact expectation value (energy)
# from the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
exact_value = result.values[0]


# print the exact expectation value (energy)
# from the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
print( f"Exact energy: {exact_value}" )

# import the IBM's Qiskit Runtime Service
# from the Qiskit IBM's Runtime
from qiskit_ibm_runtime import QiskitRuntimeService


# define the hub for the IBM's Qiskit
# Runtime Service
hub = "ibm-q-internal"

# define the group for the IBM's Qiskit
# Runtime Service
group = "deployed"

# define the project for the IBM's Qiskit
# Runtime Service
project = "default"


# create an IBM's Qiskit Runtime Service
service = QiskitRuntimeService(instance=f"{hub}/{group}/{project}")

# import the estimator, options and session
# for quantum simulations from the Qiskit IBM Runtime module
from qiskit_ibm_runtime import Estimator, Options, Session

# import the coupling map from
# the IBM's Qiskit Transpiler module
from qiskit.transpiler import CouplingMap


# define the backend to run the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime
backend = service.get_backend("simulator_statevector")


# set the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime
simulator = {
    "basis_gates": ["id", "rz", "sx", "cx", "reset"],
    "coupling_map": list(CouplingMap.from_line(length + 1)),
}


# define the number of shots for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime
shots = 10000

# import the built-in math module
import math


# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 0 (zero)
options = Options(
    simulator=simulator,
    resilience_level=0,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )


# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# import the noise model and the readout error
# from the IBM's Qiskit Aer Noise module
from qiskit_aer.noise import NoiseModel, ReadoutError


# create a noise model
noise_model = NoiseModel()


##### your code here #####

# create the measurement miss-assignments and
# correct measurement probabilities

# create the measurement miss-assignment
# probabilities for the 1st qubit,
# for the erroneous measurement outcomes
qubit0_p0_given1 = 0.2
qubit0_p1_given0 = 0.5

# create the correct measurement
# probabilities for the 1st qubit
qubit0_p0_given0 = (1 - qubit0_p0_given1)
qubit0_p1_given1 = (1 - qubit0_p1_given0)


# create the measurement miss-assignment
# probabilities for the remaining qubits k_i,
# for the erroneous measurement outcomes
qubitk_p0_given1 = 0.02
qubitk_p1_given0 = 0.05

# create the correct measurement
# probabilities for the remaining qubits k_i
qubitk_p0_given0 = (1 - qubitk_p0_given1)
qubitk_p1_given1 = (1 - qubitk_p1_given0)


# create the readout error objects

# create a readout error for the 1st qubit
readout_error_qubit0 = ReadoutError( [ [ qubit0_p0_given0, qubit0_p0_given1 ],
                                       [ qubit0_p1_given0, qubit0_p1_given1 ] ] )

# ceeate a readout error for the remaining qubits k_i
readout_error_qubitk = ReadoutError( [ [ qubitk_p0_given0, qubitk_p0_given1 ],
                                       [ qubitk_p1_given0, qubitk_p1_given1 ] ] )


# add a readout error to the noise model for the 1st qubit
noise_model.add_readout_error( readout_error_qubit0, [0] )


# for each of the remaining qubits k_i
# note: recall that even though our circuit acts on 5 qubits,
#       we will initialize a simulator with 6 qubits in order to
#       later demonstrate the potential effects of qubit choice
for k in range( 1, ( length + 1 ) ):
    
    # add a readout error to the noise model
    # for the remaining qubits k_i
    noise_model.add_readout_error( readout_error_qubitk, [k] )

    
# print the noise model created before
print(noise_model)

# submit your answer

# import the grader for the exercise 1 of the lab 5
from qc_grader.challenges.qgss_2023 import grade_lab5_ex1

# grade the exercise 1 of the lab 5
grade_lab5_ex1(noise_model)

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# considering the noise model defined before,
# and a resilience level of 0 (zero)
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=0,
    transpilation=dict( initial_layout=list( range(length) ) ),
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )
    

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# considering the noise model defined before,
# and a resilience level of 0 (zero),
# but this time, with a layout where
# the 1st qubit is not considered
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=0,
    transpilation=dict( initial_layout=list( range(1, ( length + 1 ) ) ) ),
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )


# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# considering the noise model defined before,
# and a resilience level of 1 (one),
# but this time, with a layout where
# the 1st qubit is not considered
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=1,
    transpilation=dict( initial_layout=list( range( 1, ( length + 1 ) ) ) ),
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )


# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the variable for
# the new number of shots
new_shots: int


##### your code here #####

# define the new number of shots
new_shots = 20000

# submit your answer

# import the grader for the exercise 2 of the lab 5
from qc_grader.challenges.qgss_2023 import grade_lab5_ex2

# grade the exercise 2 of the lab 5
grade_lab5_ex2(new_shots)

# import the noise model and the depolarizing error
# from the IBM's Qiskit Aer Noise module
from qiskit_aer.noise import depolarizing_error


# creata a noise model
noise_model = NoiseModel()


##### your code here #####

# create the depolarizaation probability
depolarization_prob = 0.01


# create the depolarization error objects

# create a two-qubit depolarization error for two qubits
depolarizing_error_two_quits = depolarizing_error( depolarization_prob, 2 )


# add the two-qubit depolarization error after each CNOT/CX gate
noise_model.add_all_qubit_quantum_error( depolarizing_error_two_quits, ['cx'] )


# print the noise model created before
print(noise_model)

# submit your answer

# import the grader for the exercise 3 of the lab 5
from qc_grader.challenges.qgss_2023 import grade_lab5_ex3

# grade the exercise 3 of the lab 5
grade_lab5_ex3(noise_model)

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 1 (one)
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=1,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )


# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 2 (two)
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=2,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )
    

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variances of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian,
# applying Zero-Noise Extrapolation with Noise Amplification
variances = result.metadata[0]["zne"]["noise_amplification"]["variance"]


# print the statistics for the estimated energy,
# the energy error, and the variances
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variances: {variances}")

# import Pauli error from
# the IBM's Qiskit Aer Noise module
from qiskit_aer.noise import pauli_error


# create a noise model
noise_model = NoiseModel()


##### your code here #####

# create the Pauli error probability
pauli_error_prob = 0.1


# create the Pauli error objects

# create a bit flip error for a qubit
bit_flip_error = pauli_error( [ ( "X", pauli_error_prob ),
                                ( "I", ( 1 - pauli_error_prob ) ) ] )


# add a bit flip error for a qubit,
# after each Pauli-X, Pauli-Y, Pauli-Z, and Hadamard gate
noise_model.add_all_qubit_quantum_error( bit_flip_error, ["x", "y", "z", "h"] )


# print the noise model created before
print(noise_model)

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 0 (zero)
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=0,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )
    

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 1 (one)
options = Options(
    simulator=dict(noise_model=noise_model, **simulator),
    resilience_level=1,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )
    

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 2 (two)
options = Options(
    simulator=dict(noise_model=noise_model, **simulator),
    resilience_level=2,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )


# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variances of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian,
# applying Zero-Noise Extrapolation with Noise Amplification
variances = result.metadata[0]["zne"]["noise_amplification"]["variance"]


# print the statistics for the estimated energy,
# the energy error, and the variances
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variances: {variances}")

# import Pauli error from
# the IBM's Qiskit Aer Noise module
from qiskit_aer.noise import pauli_error


# create a noise model
noise_model = NoiseModel()


##### your code here #####

# create the Pauli error probability
pauli_error_prob = 0.1


# create the Pauli error objects

# create a phase flip error for a qubit
phase_flip_error = pauli_error( [ ( "Z", pauli_error_prob ),
                                  ( "I", ( 1 - pauli_error_prob ) ) ] )


# add a phase flip error for a qubit,
# after each Pauli-X, Pauli-Y, Pauli-Z, and Hadamard gate
noise_model.add_all_qubit_quantum_error( phase_flip_error, ["x", "y", "z", "h"] )


# print the noise model created before
print(noise_model)

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 0 (zero)
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=0,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )
    

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 1 (one)
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=1,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )
    

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 2 (two)
options = Options(
    simulator=dict(noise_model=noise_model, **simulator),
    resilience_level=2,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )


# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variances of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian,
# applying Zero-Noise Extrapolation with Noise Amplification
variances = result.metadata[0]["zne"]["noise_amplification"]["variance"]


# print the statistics for the estimated energy,
# the energy error, and the variances
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variances: {variances}")

# import Reset error from
# the IBM's Qiskit Aer Noise module
from qiskit_aer.noise import reset_error


# create a noise model
noise_model = NoiseModel()


##### your code here #####

# create the Reset error probabilities
reset_0_prob = 0.2
reset_1_prob = 0.33


# create the Reset error objects

# create a Reset error for a qubit
reset_0_1_error = reset_error( reset_0_prob, reset_1_prob )


# add a Reset error for a qubit,
# after each Pauli-X, Pauli-Y, Pauli-Z, and Hadamard gate
noise_model.add_all_qubit_quantum_error( reset_0_1_error, ["x", "y", "z", "h"] )


# print the noise model created before
print(noise_model)

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 0 (zero)
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=0,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )
    

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 1 (one)
options = Options(
    simulator=dict( noise_model=noise_model, **simulator ),
    resilience_level=1,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )
    

# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variance from the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
variance = result.metadata[0]["variance"]

# compute the standard deviation of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
std = math.sqrt( variance / shots )


# print the statistics for the estimated energy,
# the energy error, the variance, and the standard error
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variance: {variance}")
print(f"Standard error: {std}")

# define the options for the quantum simulation
# to run in the backend, using the IBM's Qiskit Runtime,
# with a resilience level of 2 (two)
options = Options(
    simulator=dict(noise_model=noise_model, **simulator),
    resilience_level=2,
)


# within the context of a session of the IBM's Qiskit Runtime 
with Session( service=service, backend=backend ):
    
    # create an estimator object with the options defined before
    estimator = Estimator(options=options)
    
    # create a job for the quantum circuit implementing
    # the Heisenberg's Hamiltonian, running the Estimator
    job = estimator.run( circuit, hamiltonian, shots=shots )


# retrieve the results of the job ran by
# the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
result = job.result()


# retrieve the values of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian
experiment_value = result.values[0]

# compute the error as the distance between
# the experiment value and the exact value
error = abs(experiment_value - exact_value)

# retrieve the variances of the results of
# the job ran by the Estimator created before,
# that was ran on the quantum circuit
# implementing the Heisenberg's Hamiltonian,
# applying Zero-Noise Extrapolation with Noise Amplification
variances = result.metadata[0]["zne"]["noise_amplification"]["variance"]


# print the statistics for the estimated energy,
# the energy error, and the variances
print(f"Estimated energy: {experiment_value}")
print(f"Energy error: {error}")
print(f"Variances: {variances}")

# import the IBM's Qiskit Jupyter Tools
import qiskit.tools.jupyter

# show the table of the IBM's Qiskit version
%qiskit_version_table