walkthrough_4_optical_landscapes

# Walkthrough 4: Optical landscapes¶

## Introduction¶

While `Barrier` objects explored in the last walkthrough are quite powerful, there are instances where the limits imposed on the user by this abstraction are not flexible enough (e.g., the barrier shapes are pre-determined, etc.). In this walkthrough, we explore a more customizable way to alter the potential energy, as a function of position, experienced by the atom ensemble during the experiment phase. Our new objects consist of `LandscapeSnapshot`s , which are optical potentials specified by providing a spatially-dependent potential energy at given positions and a fixed time during the experiment phase. Dynamics are supported by allowing the user to specify many such snapshots, each with a unique time, along with control over how those snapshots are connected/interpolated point-by-point. This construction is called a `Landscape` and is the focus of this walkthrough.

NOTE: Landscape-type potentials are sourced from the same laser light as barriers and represent another abstraction available for the user to explore the "painted light" capabilities of Oqtant hardware.

### Imports and user authentication¶

In [ ]:

    from oqtant.oqtant_client import get_client
    from oqtant.schemas.quantum_matter import (
        Barrier,
        LandscapeSnapshot,
        Landscape,
        QuantumMatter,
    )

    client = get_client()

## `LandscapeSnapshot` objects¶

`LandscapeSnapshot` objects are the basis of our new abstraction. They represent the desired instantaneous optical potential applied to the atom ensemble at a particular time. Here, we will often refer to them only as snapshots.

### Object creation¶

We can construct such a snapshot by passing equal length lists of positions (in microns) and corresponding potentials (in kHz), the time (in ms) at which this snapshot should be realized, and an interpolation parameter that communicates how to connect the given data in space.

In [ ]:

    snapshot = LandscapeSnapshot(
        time=2,
        positions=[-10, -5, 0, 5, 10],
        potentials=[0, 10, 20, 15, 0],
        interpolation="LINEAR",
    )

### Visualizing the instantaneous potential energy contribution¶

Much like for barriers, we can use the `LandscapeSnapshot.show_potential()` method to visualize the potential energy contribution of a particular snapshot:

In [ ]:

    snapshot.show_potential()

We can see the underlying data used to instantiate the landscape object (orange points) and how the potential energy at other positions will be calculated based on that data (blue line) according to the interpolation choice.

### Spatial interpolation options¶

The interpolation parameter passed to the constructor of our snapshot object controls how the given points formed by the equal length lists of (positions, potentials) are connected spatially (as opposed to the previously encountered rf evaporation and barrier interpolation inputs, which controlled how points were connected in time.) Options for spatial interpolation include those options familiar to users of the scipy library and are summarized in the following table:

Interpolation parameter value | notes
--- | ---
"ZERO" | Spline interpolation at zeroth order
"SLINEAR" | Spline interpolation at first order
"QUADRATIC" | Spline interpolation at second order
"CUBIC" or "SMOOTH" | Spline interpolation at third order
"OFF" or "STEP" or "PREVIOUS" | Assumes value of previous data point
"NEXT" | Assumes value of next data point
"LINEAR" | Linear interpolation between points

Note: The total optical potential applied to the quantum matter sample is bounded below by 0 kHz (no painted light at that position) and above by 100 kHz, which under certain circumstances can alter the expected potential energy landscape. This is particularly true for high-order interpolation options, e.g. cubic, which tend to overshoot or undershoot these bounds for points close in proximity to them. Also, just as for multiple barriers that overlap, the presence of snapshots/landscapes and barriers together can lead to optical potentials that sample this energetic ceiling.

In [ ]:

    options = ["OFF", "LINEAR", "CUBIC"]
    for option in options:
        snapshot = LandscapeSnapshot(
            time=2,
            positions=[-10, -5, 0, 5, 10, 25, 30, 39],
            potentials=[0, 10, 20, 15, 0, 10, 15, 2],
            interpolation=option,
        )
        snapshot.show_potential()

As shown, the snapshot data structure allows for very flexible potential-energy profiles.

## `Landscape` objects¶

`Landscape` objects represent the dynamic potential energy as a function of position realized by connecting a series of snapshots together in time. A valid landscape needs at least two constituent snapshots to define the potential at, in this case, the start and end time that the landscape should be applied. At intermediate times between the provided snapshots, the overall landscape / potential energy as a function of position is linearly interpolated point-by-point (in position). This interpolation behavior is not user configurable.

NOTE: The time values of individual snapshots that make up the overall Landscape object must be at least 1 ms apart.

Let us demonstrate the instantiation of an landscape that evolves between a narrow/short barrier-like object to a wider/taller one with a different spatial interpolation style.

In [ ]:

    snapshot1 = LandscapeSnapshot(
        time=2,
        positions=[-10, -5, 0, 5, 10],
        potentials=[0, 10, 20, 15, 0],
        interpolation="LINEAR",
    )

    snapshot2 = LandscapeSnapshot(
        time=5,
        positions=[-20, -10, 0, 10, 20],
        potentials=[0, 15, 40, 10, 0],
        interpolation="CUBIC",
    )

    landscape = Landscape(snapshots=[snapshot2, snapshot1])

    initializing Landscape...
    copying to optical landscape
    copying to optical landscape
    optical landscapes length: 2

We can observe this applied potential energy derived from our landscape object at any particular time using the `Landscape.show_potential()` method:

In [ ]:

    landscape.show_potential(times=[2, 3, 4, 5])

## Adding a `Landscape` object to `QuantumMatter`¶

Similar to how we added barriers, we can also add landscapes to our quantum matter objects, in this case by providing a landscape parameter during instantiation. In the below example, we include both a simple `Barrier` as well as our new `Landscape`:

In [ ]:

    # define a simple barrier, just as an example, that lives until t = 13
    barrier = Barrier(
        position=30, height=30, width=3, shape="GAUSSIAN", birth=3, lifetime=7
    )
    barrier.evolve(duration=3, height=15, position=-30)

    # and the dynamic landscape, consisting of two snapshots for this example
    snapshot1 = LandscapeSnapshot(
        time=0,
        positions=[-10, -5, 0, 5, 10],
        potentials=[0, 10, 20, 15, 0],
        interpolation="LINEAR",
    )
    snapshot2 = LandscapeSnapshot(
        time=15,
        positions=[-20, -10, 0, 10, 20],
        potentials=[0, 15, 40, 10, 0],
        interpolation="LINEAR",
    )
    landscape = Landscape(snapshots=[snapshot1, snapshot2])

    # and construct the program
    matter = QuantumMatter(
        name="paint 1d job trial",
        temperature=100,
        lifetime=20,
        time_of_flight=10,
        barriers=[barrier],
        landscape=landscape,
    )

    initializing Landscape...
    copying to optical landscape
    copying to optical landscape
    optical landscapes length: 2

### Visualizing the overall potential energy¶

Just as in our examples in previous walkthroughs that did not include the optional landscape parameter, we can show the total spatial potential energy as a function of position for various experiment times using the `QuantumMatter.show_potential()` method. The total potential will include contributions from both our QuantumMatter object's constituent barriers, the new landscape, as well as the background magnetic trapping field:

In [ ]:

    matter.show_potential(times=[2, 4, 8, 16])

As shown, the total potential includes both the evolving landscape as well as the scanning barrier (in addition to the magnetic trapping potential).

### Submitting to QMS¶

In [ ]:

    my_job = client.convert_matter_to_job(matter=matter)

In [ ]:

    print(my_job.model_dump())

    {'name': 'paint 1d job trial', 'origin': None, 'status': <JobStatus.PENDING: 'PENDING'>, 'display': True, 'qpu_name': <QPUName.UNDEFINED: 'UNDEFINED'>, 'inputs': [{'job_id': None, 'run': 1, 'values': {'end_time_ms': 20.0, 'image_type': <ImageType.TIME_OF_FLIGHT: 'TIME_OF_FLIGHT'>, 'time_of_flight_ms': 10.0, 'rf_evaporation': {'times_ms': [-1100.0, -1050.0, -800.0, -300.0, 0.0], 'frequencies_mhz': [21.12, 12.12, 5.12, 0.62, 0.01], 'powers_mw': [600.0, 800.0, 600.0, 400.0, 400.0], 'interpolation': <RfInterpolationType.LINEAR: 'LINEAR'>}, 'optical_barriers': [{'times_ms': [3.0, 10.0, 13.0], 'positions_um': [30.0, 30.0, -30.0], 'heights_khz': [30.0, 30.0, 15.0], 'widths_um': [3.0, 3.0, 3.0], 'interpolation': <InterpolationType.LINEAR: 'LINEAR'>, 'shape': <ShapeType.GAUSSIAN: 'GAUSSIAN'>}], 'optical_landscape': {'interpolation': <InterpolationType.LINEAR: 'LINEAR'>, 'landscapes': [{'time_ms': 0.0, 'potentials_khz': [0.0, 10.0, 20.0, 15.0, 0.0], 'positions_um': [-10.0, -5.0, 0.0, 5.0, 10.0], 'spatial_interpolation': <InterpolationType.LINEAR: 'LINEAR'>}, {'time_ms': 15.0, 'potentials_khz': [0.0, 15.0, 40.0, 10.0, 0.0], 'positions_um': [-20.0, -10.0, 0.0, 10.0, 20.0], 'spatial_interpolation': <InterpolationType.LINEAR: 'LINEAR'>}]}, 'lasers': None}, 'output': None, 'notes': None}], 'active_run': 1, 'external_id': None, 'time_submit': None, 'pix_cal': 8.71, 'job_type': <JobType.PAINT_1D: 'PAINT_1D'>, 'input_count': 1}

In [ ]:

    print(my_job.job_type)

    PAINT_1D

In [ ]:

    my_job_id = client.submit(matter, track=True)

    Submitting 1 job(s):

    ---------------------------------------------------------------------------
    HTTPError                                 Traceback (most recent call last)
    File ~/Documents/dev/pybert/oqtant/oqtant_client.py:374, in OqtantClient.submit_job(self, job, write)
        373 try:
    --> 374     response.raise_for_status()
        375 except RequestException as err:

    File ~/Documents/dev/pybert/.venv/lib/python3.10/site-packages/requests/models.py:1021, in Response.raise_for_status(self)
       1020 if http_error_msg:
    -> 1021     raise HTTPError(http_error_msg, response=self)

    HTTPError: 500 Server Error: Internal Server Error for url: https://oqtant.infleqtion.com/api/jobs

    The above exception was the direct cause of the following exception:

    OqtantRequestError                        Traceback (most recent call last)
    Cell In[12], line 1
    ----> 1 my_job_id = client.submit(matter, track=True)

    File ~/Documents/dev/pybert/oqtant/oqtant_client.py:130, in OqtantClient.submit(self, matter, track, write, filename, target)
        116 """
        117 Submits a QuantumMatter object for execution, returns the resulting job id.
        118
       (...)
        127     str: The resulting Job ID of the resulting job.
        128 """
        129 job = self.convert_matter_to_job(matter)
    --> 130 return self.run_jobs(job_list=[job], track_status=track)[0]

    File ~/Documents/dev/pybert/oqtant/oqtant_client.py:411, in OqtantClient.run_jobs(self, job_list, track_status, write)
        409 self.__print(f"Submitting {len(job_list)} job(s):")
        410 for job in job_list:
    --> 411     response = self.submit_job(job=job, write=write)
        412     external_id = response["job_id"]
        413     queue_position = response["queue_position"]

    File ~/Documents/dev/pybert/oqtant/oqtant_client.py:376, in OqtantClient.submit_job(self, job, write)
        374     response.raise_for_status()
        375 except RequestException as err:
    --> 376     raise api_exceptions.OqtantRequestError(
        377         "Failed to submit job to Oqtant"
        378     ) from err
        379 response_data = response.json()
        380 if write:

    OqtantRequestError: Failed to submit job to Oqtant

### Retrieving results¶

We retrieve the results of our job in the normal way:

In [ ]:

    my_job = client.get_job(my_job_id)

## Advanced options and discussion¶

### Mapped job type¶

Our QuantumMatter object that includes a non-null input for the landscape exceeds the capabilities of an Oqtant BARRIER job. Instead, it gets mapped to a `PAINT_1D` job type. We can double check this by evaluating the following:

In [ ]:

    print(my_job.job_type)