walkthrough_5_experimentation_and_batch_jobs

# Walkthrough 5: Experimentation and batch jobs¶

## Introduction¶

While results from a single job of the Oqtant hardware are often interesting, commonly multiple jobs are needed to either average results or to scan control parameters to map out dependencies and trends. In this walkthrough, we will explore ways of submitting and managing lists of QuantumMatter objects and resulting OqtantJobs.

### Imports and user authentication¶

In [ ]:

    from oqtant.schemas.quantum_matter import QuantumMatterFactory as qmf
    from oqtant.oqtant_client import get_oqtant_client
    from oqtant.util.auth import notebook_login
    import matplotlib.pyplot as plt

    oqtant_account = notebook_login()
    oqtant_account

In [ ]:

    client = get_oqtant_client(oqtant_account.access_token)

## Submitting a list of QuantumMatter objects to generate many independent jobs¶

While it is possible to construct QuantumMatter objects one at a time and submit them to Oqtant QMS (via the OqtantClient) individually, one can also submit many objects at once. Here, we will demonstrate how to create such a list, each element with individually tuned inputs, and submit this list to generate many jobs in one go.

### Creating a list of QuantumMatter objects¶

Let us begin by making a list of QuantumMatter objects that each have a different target temperature.

In [ ]:

    N = 4
    matters = [
        qmf.create_quantum_matter(
            temperature=50 * (n + 1), name="quantum matter run " + str(n + 1) + "/" + str(N)
        )
        for n in range(N)
    ]

    print(list(map(type, matters)))

### Submitting the list to the client¶

We can then submit the list to the client. In this case, the `OqtantClient.submit_list()` method generates many independent jobs to be run on the hardware and returns a corresponding list of job ids. Each of these jobs enters Oqtant's queueing system at nearly the same time, so they will likely be executed near each other in time, depending on current queue usage.

In [ ]:

    my_job_ids = client.submit_list(matter_list=matters, track=True)

### Accessing job results¶

Once all our submitted jobs are complete, we access the results in exactly the same way we would do had we submitted the programs individually:

In [ ]:

    # retrieve jobs from server one at a time, creating corresponding local job objects
    my_jobs = [client.get_job(id) for id in my_job_ids]

    # access the results individually and plot them together
    lns = []
    lbls = []
    plt.figure(figsize=(5, 4))
    for job in my_jobs:
        (ln,) = plt.plot(job.get_slice(axis="x"))
        plt.xlabel("x position (pixels)")
        plt.ylabel("OD")
        lns.append(ln)
        lbls.append(str(job.output.temperature_nk))
    plt.legend(lns, lbls, loc="upper right", title="temp (nK)")
    plt.show()

    plt.figure(figsize=(5, 4))
    plt.plot(
        [job.output.temperature_nk for job in my_jobs],
        [
            100 * job.output.condensed_atom_number / job.output.tof_atom_number
            if job.output.tof_atom_number > 0
            else 0
            for job in my_jobs
        ],
        "-o",
    )
    plt.xlabel("temperature (nK)")
    plt.ylabel("condensed fraction (%)")
    plt.show()

## Submitting a list of QuantumMatter objects to generate a single "batch" job¶

There is also the option to submit a list of QuantumMatter objects as a single batch job, which guarantees that the sequence executes sequentially on the hardware. This feature is useful for detailed experimentation or investigation, where subsequent shots need to be compared to each other in detail. Using sequential hardware shots reduces system drift or inconsistency. In the case of bundling into a single batch job, only one job id will be generated. Programmatically, the batch job will be composed of multiple runs on Oqtant's hardware, and retrieving job results (see below) will require specifying the run number $1 \ldots N$ when fetching the job results, where there were $N$ runs in the job.

NOTE: The resulting name of a batch job will default to the name given to the first QuantumMatter object in the provided list. Alternatively, a global name can be provided at the point of submission to the client. Also be aware that for a batch job each individual run is charged against your job quota. A single batch job will naturally contain multiple runs.

### Creating a list of QuantumMatter objects¶

In [ ]:

    # create a list of QuantumMatter objects
    N = 2
    matters = [
        qmf.create_quantum_matter(temperature=50 * (n + 1), name=str(n + 1) + "/" + str(N))
        for n in range(N)
    ]

### Submitting the list to the client as a batch job¶

We can now submit our list of QuantumMatter objects to generate a batch job using the `OqtantClient.submit_list_as_batch()` method:

In [ ]:

    # submit the list as a batch that will run sequentially on the hardware
    # returns only a single job id, e.g., "1cdb4ff7-c5ed-46d3-a667-8b12f0cd1349"
    my_batch_job_id = client.submit_list_as_batch(
        matter_list=matters,
        name="a batch!",  # global batch name
    )

### Accessing batch job results¶

We can now retrieve the batch job run results one at a time by specifying the desired run number in the `OqtantClient.get_job()` method. If omitted (as for non-batch jobs with just a single run), the data for the first run will be fetched.

NOTE: The added complication of managing multiple runs within a single job is is an unavoidable consequence of ensuring that the runs execute sequentially on the hardware.

In [ ]:

    # create local jobs based on single runs of the batch job
    first_run = client.get_job(job_id=my_batch_job_id, run=1)
    second_run = client.get_job(job_id=my_batch_job_id, run=2)

In [ ]:

    print(first_run.id, "run", first_run.inputs[0].run, "of", first_run.input_count)
    print(second_run.id, "run", second_run.inputs[0].run, "of", second_run.input_count)

    print(my_batch_job_first_run.output.temperature_nk)
    print(my_batch_job_second_run.output.temperature_nk)

We can plot the results together, as above, but augment our approach at extracting the data:

In [ ]:

    my_runs = []
    for n in range(N):
        my_runs.append(client.get_job(job_id=my_batch_job_id, run=n + 1))

    lns = []
    lbls = []
    plt.figure(figsize=(5, 4))
    for run in my_runs:
        (ln,) = plt.plot(run.get_slice(axis="x"))
        plt.xlabel("x position (pixels)")
        plt.ylabel("OD")
        lns.append(ln)
        lbls.append(str(run.output.temperature_nk))
    plt.legend(lns, lbls, loc="upper right", title="temp (nK)")
    plt.show()

    plt.figure(figsize=(5, 4))
    plt.plot(
        [run.output.temperature_nk for run in my_runs],
        [
            100 * run.output.condensed_atom_number / run.output.tof_atom_number
            if run.output.tof_atom_number > 0
            else 0
            for run in my_runs
        ],
        "-o",
    )
    plt.xlabel("temperature (nK)")
    plt.ylabel("condensed fraction (%)")
    plt.show()

### Saving batch job results¶

When we fetch results from a batch job, our instantiated local job only contains output data for the specified run. However, fetching multiple runs will result in many local jobs that share the same job id. When saving the data associated with these jobs, the job id (which is used as the default filename) is therefore not unique.

In [ ]:

    client.write_job_to_file(my_batch_job_first_run)
    client.write_job_to_file(my_batch_job_second_run)

In this case, the `OqtantClient.write_job_to_file()` method automatically appends the run information in the format id_run_n_of_N.txt.