From 523a53fe8b30a0716cc9dc10041411ce83a28277 Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Thu, 2 Apr 2026 17:36:11 -0400 Subject: [PATCH 01/36] Attempting to fix multiple instances of the buffer thread starting --- src/gui.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/gui.py b/src/gui.py index 61d4f34..213864d 100644 --- a/src/gui.py +++ b/src/gui.py @@ -34,9 +34,6 @@ def __init__(self): self.readout = create_buffer_instance() - self.readout.run() - - def plotting_panel(self): with ui.matplotlib().figure as fig: @@ -142,10 +139,20 @@ def start(self): ui.run(port = 8081) def on_shutdown(self): + print("Starting on_shutdown") self.readout.join() + def on_startup(self): + print("Starting on_startup") + self.readout.run() @app.on_shutdown def shutdown(): global _gui_instances for inst in _gui_instances: inst.on_shutdown() + +@app.on_startup +def startup(): + global _gui_instances + for inst in _gui_instances: + inst.on_startup() From dbaff7a91044c0fc3e40d8ab7df5ff93e74df6fd Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Thu, 2 Apr 2026 20:44:07 -0400 Subject: [PATCH 02/36] Some refactoring of the interaction with NiceGUI --- src/buffered_readout.py | 1 - src/gui.py | 65 +++++++++++++++++++++-------------------- src/main.py | 10 +++---- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/buffered_readout.py b/src/buffered_readout.py index 258cde3..c0326c6 100644 --- a/src/buffered_readout.py +++ b/src/buffered_readout.py @@ -22,7 +22,6 @@ def __init__(self): A class to handle the asynchronous buffered readout of the SET current for autotuning devices. ''' - global __BufferExists__ assert not __BufferExists__, "Error: Readout buffer already exists!!" diff --git a/src/gui.py b/src/gui.py index 213864d..7c009a3 100644 --- a/src/gui.py +++ b/src/gui.py @@ -13,9 +13,10 @@ import os import threading import time -from buffered_readout import create_buffer_instance +from buffered_readout import create_buffer_instance, buffered_readout +from dataclasses import dataclass +import time -_gui_instances = [] class tuner_gui: def __init__(self): @@ -29,10 +30,11 @@ def __init__(self): self.lipsum_text = 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quis praesentium cumque magnam odio iure quidem, quod illum numquam possimus obcaecati commodi minima assumenda consectetur culpa fuga nulla ullam. In, libero.' print(threading.current_thread().name) - global _gui_instance - _gui_instances.append(self) self.readout = create_buffer_instance() + self.readout.run() + + self.start_time = time.time() def plotting_panel(self): @@ -74,12 +76,15 @@ def home_page(self): ui.button('sleep', on_click = lambda : time.sleep(10)) - self.liveplot = ui.matplotlib(figsize = (3,2)) + self.liveplot = ui.matplotlib(figsize = (4, 3)) fig = self.liveplot.figure self.ax = fig.subplots(1,1) + self.ax.set_xlabel("Time (s)") + self.ax.set_ylabel('Signal (V)') xs = np.linspace(-1, 1) self.line = self.ax.plot(xs, np.sin(xs)) + fig.tight_layout() self.liveplot.update() ui.timer(0.05, self.update_liveplot) @@ -87,34 +92,27 @@ def home_page(self): self.n = 0 - def update_liveplot(self): + def update_liveplot(self): retval = self.readout.get_buffer() if retval is None: return else: data, times = retval + + times_offset = np.array(times) - self.start_time self.line[0].set_ydata(data) - self.line[0].set_xdata(times) - self.ax.set_xlim(min(times), max(times)) + self.line[0].set_xdata(times_offset) + self.ax.set_xlim(min(times_offset), max(times_offset)) self.ax.set_ylim(-0.5, 1.5) - #self.ax.relim() - #self.ax.autoscale_view() - #for l in self.ax.lines: - #l.remove() - #self.ax.clear() - #self.ax.plot(times, data) - - #self.liveplot.figure.canvas.draw() - self.liveplot.figure.tight_layout() self.liveplot.update() - ui.update() + #ui.update() def on_abort(self): ui.notify('Aborting...') - def start(self): + def root_page(self): with ui.header().classes(replace='row items-center') as header: with ui.dropdown_button('', icon = 'menu', auto_close=True): ui.item('Export', on_click=lambda : ui.notify("You clicked export")) @@ -137,22 +135,25 @@ def start(self): with ui.tab_panel('Pinch-offs'): ui.label('Content of C') - ui.run(port = 8081) def on_shutdown(self): print("Starting on_shutdown") self.readout.join() - def on_startup(self): - print("Starting on_startup") - self.readout.run() -@app.on_shutdown -def shutdown(): - global _gui_instances - for inst in _gui_instances: - inst.on_shutdown() +gui : tuner_gui @app.on_startup -def startup(): - global _gui_instances - for inst in _gui_instances: - inst.on_startup() +def start_tuner_gui(): + print("Starting the GUI...") + global gui + gui = tuner_gui() + +@app.on_shutdown +def stop_tuner_gui(): + print("Stopping the gui") + global gui + gui.on_shutdown() + +@ui.page('/') +def tuner_gui_root_page(): + global gui + gui.root_page() diff --git a/src/main.py b/src/main.py index fb6c542..5267afa 100644 --- a/src/main.py +++ b/src/main.py @@ -5,10 +5,8 @@ Entry point to the auto tuner. This ''' -from gui import tuner_gui -if __name__ in {"__main__", "__mp_main__"}: - print("Creating") - gui = tuner_gui() - print("Starting") - gui.start() +from nicegui import ui +import gui + +ui.run(port = 8081) From a0092e5df701beb88a9baca0739ee5d09af5b921 Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Thu, 2 Apr 2026 20:51:24 -0400 Subject: [PATCH 03/36] additional refactoring --- src/gui.py | 24 +----------------------- src/main.py | 23 +++++++++++++++++++++-- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/gui.py b/src/gui.py index 7c009a3..126fe80 100644 --- a/src/gui.py +++ b/src/gui.py @@ -13,8 +13,7 @@ import os import threading import time -from buffered_readout import create_buffer_instance, buffered_readout -from dataclasses import dataclass +from buffered_readout import create_buffer_instance import time @@ -25,9 +24,6 @@ def __init__(self): :param self: ''' - global _FirstPass - - self.lipsum_text = 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quis praesentium cumque magnam odio iure quidem, quod illum numquam possimus obcaecati commodi minima assumenda consectetur culpa fuga nulla ullam. In, libero.' print(threading.current_thread().name) @@ -139,21 +135,3 @@ def on_shutdown(self): print("Starting on_shutdown") self.readout.join() -gui : tuner_gui - -@app.on_startup -def start_tuner_gui(): - print("Starting the GUI...") - global gui - gui = tuner_gui() - -@app.on_shutdown -def stop_tuner_gui(): - print("Stopping the gui") - global gui - gui.on_shutdown() - -@ui.page('/') -def tuner_gui_root_page(): - global gui - gui.root_page() diff --git a/src/main.py b/src/main.py index 5267afa..1e8ffe6 100644 --- a/src/main.py +++ b/src/main.py @@ -6,7 +6,26 @@ ''' -from nicegui import ui -import gui +from nicegui import app, ui +from gui import tuner_gui + +gui : tuner_gui + +@app.on_startup +def start_tuner_gui(): + print("Starting the GUI...") + global gui + gui = tuner_gui() + +@app.on_shutdown +def stop_tuner_gui(): + print("Stopping the gui") + global gui + gui.on_shutdown() + +@ui.page('/') +def tuner_gui_root_page(): + global gui + gui.root_page() ui.run(port = 8081) From 75411f4dd1b6350d4119a46aea62b3610c480009 Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Sat, 4 Apr 2026 09:05:54 -0400 Subject: [PATCH 04/36] Testing the experiment thread --- src/experiment_thread.py | 18 ++++++++++++------ src/gui.py | 13 ++++++++----- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/experiment_thread.py b/src/experiment_thread.py index a670985..eafc18d 100644 --- a/src/experiment_thread.py +++ b/src/experiment_thread.py @@ -12,6 +12,7 @@ import threading from queue import PriorityQueue +from collections.abc import Callable class ExperimentThread: @@ -23,7 +24,7 @@ def __init__(self): self.abort_event = threading.Event() self.shutdown_event = threading.Event() self.job_queue = PriorityQueue() - self.THREAD_NAME = "experimental_thread" + self.THREAD_NAME = "ExperimentThread" self.thread = threading.Thread(target = self.__thread_loop__, name = self.THREAD_NAME) def run(self): @@ -31,7 +32,8 @@ def run(self): self.thread.start() def join(self): - + print("Stopping the experiment thread...") + self.shutdown_event.set() self.thread.join() def __assert_correct_thread__(self): @@ -39,10 +41,10 @@ def __assert_correct_thread__(self): assert threading.current_thread().name == self.THREAD_NAME, f"The current thread, {threading.current_thread().name}, is not the Experiment Thread." def add_job(self, - f: callable, + f: Callable, args, priority: int = 1): - + print(args) self.job_queue.put((priority,(f, args))) def abort(self): @@ -50,9 +52,10 @@ def abort(self): self.abort_event.set() - def __thread_loop__(self, job): + def __thread_loop__(self): + print("Starting the Experiment Thread Worker") - while not self.shutdown_event.set(): + while not self.shutdown_event.is_set(): self.job_event.wait(timeout = 1) @@ -60,6 +63,8 @@ def __thread_loop__(self, job): priority, data = self.job_queue.get() f, args = data + + print(f"Doing job: {data}") f(*args, self.abort_event) @@ -67,4 +72,5 @@ def __thread_loop__(self, job): while self.abort_event.is_set(): self.job_queue.get() + self.job_queue.task_done() diff --git a/src/gui.py b/src/gui.py index 126fe80..c4f8339 100644 --- a/src/gui.py +++ b/src/gui.py @@ -15,6 +15,7 @@ import time from buffered_readout import create_buffer_instance import time +from experiment_thread import ExperimentThread class tuner_gui: @@ -25,13 +26,18 @@ def __init__(self): :param self: ''' self.lipsum_text = 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quis praesentium cumque magnam odio iure quidem, quod illum numquam possimus obcaecati commodi minima assumenda consectetur culpa fuga nulla ullam. In, libero.' - print(threading.current_thread().name) self.readout = create_buffer_instance() self.readout.run() self.start_time = time.time() + self.exp_thread = ExperimentThread() + self.exp_thread.run() + + test_func = lambda a, e: print(a) + self.exp_thread.add_job(test_func, ("Hello World!",)) + def plotting_panel(self): with ui.matplotlib().figure as fig: @@ -67,10 +73,6 @@ def home_page(self): config_files = os.listdir('../configs') config_dict = {i : config_files[i] for i in range(len(config_files))} - - ui.button('Print Thread', on_click= lambda : print(threading.current_thread().name)) - - ui.button('sleep', on_click = lambda : time.sleep(10)) self.liveplot = ui.matplotlib(figsize = (4, 3)) @@ -134,4 +136,5 @@ def root_page(self): def on_shutdown(self): print("Starting on_shutdown") self.readout.join() + self.exp_thread.join() From 7390b1e188ef573d930543a746fe8c86d722993f Mon Sep 17 00:00:00 2001 From: VanOschB Date: Sat, 4 Apr 2026 23:03:57 -0400 Subject: [PATCH 05/36] Copying the GUI commit over to core + rewriting set_voltage_configuration Brought the most recent GUI commit from today to core. Also, reordered the set_voltage_configuration method to eliminate the intermediate and done logic. This optimizes computation/ memory; now, the idea is to check at each voltage step if the set voltage is correct and won't spike the device, instead of collecting each intermediate step. --- src/gui.py | 282 +++++++++++++++++++++++++++++++++++++------ src/write_control.py | 171 +++++++++++++++++++------- 2 files changed, 374 insertions(+), 79 deletions(-) diff --git a/src/gui.py b/src/gui.py index c4f8339..e8de1d8 100644 --- a/src/gui.py +++ b/src/gui.py @@ -6,6 +6,7 @@ As of now, we are using the nicegui web server as the user interface for the auto tuner. ''' +# Imports import numpy as np import matplotlib.pyplot as plt @@ -25,21 +26,137 @@ def __init__(self): :param self: ''' - self.lipsum_text = 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quis praesentium cumque magnam odio iure quidem, quod illum numquam possimus obcaecati commodi minima assumenda consectetur culpa fuga nulla ullam. In, libero.' + global _FirstPass + + # print(threading.current_thread().name) + global _gui_instances + _gui_instances.append(self) self.readout = create_buffer_instance() self.readout.run() - self.start_time = time.time() + # The below methods define the layout of the GUI + + def start(self): + + """ + The method that intialises the gui. As of now, it also defines the main page of the within itself. + + params: + self: + """ + + self.header() + + # I tried putting these splitters into a separate function, but then the gui wouldn't start. + + with ui.splitter(value = 54, limits = (54,54)) as splitter1: + + with splitter1.before: + + with ui.dropdown_button('', icon = 'menu', auto_close=True): + ui.item('Load Config Files', on_click=lambda : ui.notify("Fetching Configuration Files...")) + ui.item('Instrument Information', on_click=lambda : ui.notify("Loading Instrument Information...")) + ui.item('Device Information', on_click=lambda : ui.notify("Loading Device Information...")) + + stages = ['Setup','Bootstrapping','Coarse Tuning','Virtual Gating','Charge State Tuning','Fine Tuning'] + + with ui.tabs() as tabs: + + for stage in stages: + ui.tab(stage) + + with ui.tab_panels(tabs, value='Home').classes('w-full'): + + with ui.tab_panel('Setup'): + + ui.button('Connect to instruments', on_click = self.on_connect) + + ui.button('Autotune', on_click = self.on_autotune) + + config_files = os.listdir('../configs') + config_dict = {i : config_files[i] for i in range(len(config_files))} + + with ui.tab_panel('Bootstrapping'): + + ui.label('Bootstrapping Information') + + with ui.tab_panel('Coarse Tuning'): + + ui.label('Coarse Tuning Information') + + with ui.tab_panel('Virtual Gating'): + + ui.label('Virtual Gating Information') + + with ui.tab_panel('Charge State Tuning'): + + ui.label('Charge State Tuning') + + with ui.tab_panel('Fine Tuning'): + + ui.label('Fine Tuning Information') + + with splitter1.after: + + with ui.splitter(horizontal = True) as splitter2: + + with splitter2.before: + + self.live_plot_window() + + with splitter2.after: + + ui.label('Logger Information').classes('ml-2') - self.exp_thread = ExperimentThread() - self.exp_thread.run() - test_func = lambda a, e: print(a) - self.exp_thread.add_job(test_func, ("Hello World!",)) + ui.timer(0.05, self.update_liveplot) + self.n = 0 + self.footer() + + ui.run(port = 8081) + + def header(self): + + """ + The method that defines the header of the gui. + + params: + self: + """ + + with ui.header().classes(replace='row items-center') as header: + ui.label('Header') + + def footer(self): + + """ + The method that intialises the footer of the gui. + + params: + self: + """ + + with ui.footer(value=True) as footer: + ui.label('Footer') + ui.button('ABORT', on_click = self.on_abort, color='red') + + # The below methods define the features of the GUI + + def results_plot_panel(self): + + """ + The method that defines the results plots. This method takes the output plots from data_analysis + and displays them in its corresponding autotuning stage tab. + + params: + self: + results: + + + """ - def plotting_panel(self): with ui.matplotlib().figure as fig: #fig = plt.gcf() axs = fig.subplots(1, 2) @@ -47,34 +164,18 @@ def plotting_panel(self): axs[0].plot(xs, np.sin(xs)) axs[1].plot(xs, np.cos(xs)) fig.tight_layout() + + def live_plot_window(self): - def right_panel(self): - with ui.splitter(horizontal = True) as splitter: - with splitter.before: - self.plotting_panel() - with splitter.after: - ui.label('Status') - ui.label('Autotuner: idle') - ui.label('Instrmuents...') - ui.label(self.lipsum_text) - - def split_view(self, page): - with ui.splitter() as splitter: - with splitter.after: - self.right_panel() - with splitter.before: - page() + """ + The method that defines the live plot window, which streams the measurement of our readout instrument. - def home_page(self): - ui.label('Home Page stuff') - ui.button('Connect to instruments') - ui.button('Autotune') - ui.label(self.lipsum_text) + params: + self: - config_files = os.listdir('../configs') - config_dict = {i : config_files[i] for i in range(len(config_files))} - - self.liveplot = ui.matplotlib(figsize = (4, 3)) + """ + + self.liveplot = ui.matplotlib(figsize = (30,20)) fig = self.liveplot.figure self.ax = fig.subplots(1,1) @@ -82,7 +183,9 @@ def home_page(self): self.ax.set_ylabel('Signal (V)') xs = np.linspace(-1, 1) self.line = self.ax.plot(xs, np.sin(xs)) - fig.tight_layout() + self.ax.set_xlabel('time (s)', fontsize = 75) + self.ax.set_ylabel('Current (A)', fontsize = 75) + self.ax.tick_params(labelsize = 50) self.liveplot.update() ui.timer(0.05, self.update_liveplot) @@ -104,10 +207,117 @@ def update_liveplot(self): self.ax.set_ylim(-0.5, 1.5) self.liveplot.update() - #ui.update() + ui.update() + + + def experiment_progress_bar(): + + pb = ui.linear_progress() + + def split_view(self, page1, page2, horizontal_split: bool = False): + + """ + This method creates a split view of two specified pages. + + params: + self: + page1: The first page of the split. Depending on if horizontal_split is True or False, + this page will be on the top, or the left, respectively. + page2: The second page of the split. Depending on if horizontal_split is True or False, + this page will be on the bottom, or the right, respectively. + horizontal_split: Determines whether the split creates a left/right splitting, or a top/bottom splitting. True implies + the split is horizontal, meaning it will be top/bottom. False means a vertical split, or left/right splitting. + """ + + with ui.splitter(horizontal = horizontal_split) as splitter: + with splitter.after: + page2() + with splitter.before: + page1() + + # The below methods define all the button press logic of the GUI + + def on_connect(self): + + """ + This method will connect to the load_config_files method in write control and XXX in buffered readout, to initialise connections + to the instruments for setting voltages, and the buffered readout to start capturing data on the live plotting window. + + params: + self: + """ + + pass + + def on_autotune(self): + + """ + This method starts the defined autotuning protocol. Currently, the autotuning protocol is specific to the Intel Tunnel Falls + devices, by following the autotuning_protocol.py file. This will be updated to allow for application to general devices. + + + params: + self: + + """ + + pass + + def experiment_progress_bar(): + + pb = ui.linear_progress() + + def split_view(self, page1, page2, horizontal_split: bool = False): + + """ + This method creates a split view of two specified pages. + + params: + self: + page1: The first page of the split. Depending on if horizontal_split is True or False, + this page will be on the top, or the left, respectively. + page2: The second page of the split. Depending on if horizontal_split is True or False, + this page will be on the bottom, or the right, respectively. + horizontal_split: Determines whether the split creates a left/right splitting, or a top/bottom splitting. True implies + the split is horizontal, meaning it will be top/bottom. False means a vertical split, or left/right splitting. + """ + + with ui.splitter(horizontal = horizontal_split) as splitter: + with splitter.after: + page2() + with splitter.before: + page1() + + # The below methods define all the button press logic of the GUI + + def on_connect(self): + + """ + This method will connect to the load_config_files method in write control and XXX in buffered readout, to initialise connections + to the instruments for setting voltages, and the buffered readout to start capturing data on the live plotting window. + + params: + self: + """ + + pass + + def on_autotune(self): + + """ + This method starts the defined autotuning protocol. Currently, the autotuning protocol is specific to the Intel Tunnel Falls + devices, by following the autotuning_protocol.py file. This will be updated to allow for application to general devices. + + params: + self: + + """ + + pass def on_abort(self): + ui.notify('Aborting...') def root_page(self): @@ -134,7 +344,7 @@ def root_page(self): ui.label('Content of C') def on_shutdown(self): - print("Starting on_shutdown") + self.readout.join() - self.exp_thread.join() + diff --git a/src/write_control.py b/src/write_control.py index dec3773..b63e2a7 100644 --- a/src/write_control.py +++ b/src/write_control.py @@ -15,7 +15,7 @@ from pathlib import Path import pandas as pd - +import math import numpy as np import scipy as sp @@ -400,20 +400,16 @@ def set_voltage_configuration(self, # Now, we set up some lists to hold the voltage values - intermediate = [] - done = set() - - prevvals = {} gate_params = {} - gate_steps = {} + start_vals = {} + step_sizes = {} # Now, we map the gate to the source and save the correspondance - gate_to_source = {} + gate_to_source = {gate: instrument for source_name, instrument in self.voltage_sources.items() + for gate in self.voltage_source_names_check[source_name]} - for source_name, instrument in self.voltage_sources.items(): - for gate in self.voltage_source_names_check[source_name]: - gate_to_source[gate] = instrument + # Now, we gather the parameters to set for gate, target in voltage_configuration.items(): @@ -421,53 +417,46 @@ def set_voltage_configuration(self, param = getattr(instrument, gate) gate_params[gate] = param - prevvals[gate] = float(param.get()) + start_vals[gate] = float(param.get()) step_param = getattr(instrument, f"{gate}_step", None) - - gate_steps[gate] = step_param() if step_param else stepsize + step_sizes[gate] = step_param() if step_param else stepsize - # Now, we generate the ramp + # Now, we determine the number of steps needed for each gate - while len(done) < len(voltage_configuration): + steps_needed = {} - step = {} - - for gate, target in voltage_configuration.items(): - - if gate in done: - continue + for gate, target in voltage_configuration.items(): - prev = prevvals[gate] - step_size = gate_steps[gate] + dv = abs(target - start_vals[gate]) + steps_needed[gate] = math.ceil(dv / step_sizes[gate]) - dv = target - prev + max_steps = max(steps_needed.values()) - if abs(dv) <= step_size: - step[gate] = target - done.add(gate) - else: - step[gate] = prev + step_size * (1 if dv > 0 else -1) + # Finally, we conduct the ramp - prevvals[gate] = step[gate] + for step in range(1, max_steps + 1): - intermediate.append(step) + for gate, target in voltage_configuration.items(): - # Finally, we apply the ramp + start = start_vals[gate] + step_size = step_sizes[gate] - for step in intermediate: + direction = np.sign(target - start) + value = start + direction * step * step_size - for gate, voltage in step.items(): - gate_params[gate].set(voltage) + if direction > 0: + value = min(value, target) + else: + value = max(value, target) - # This lets us sleep once per step - for instrument in self.voltage_sources.values(): - delay_param = getattr(instrument, "smooth_timestep", None) - if delay_param: - time.sleep(delay_param()) - break + gate_params[gate].set(value) - return None + for instrument in self.voltage_sources.values(): + delay_param = getattr(instrument, "smooth_timestep", None) + if delay_param: + time.sleep(delay_param()) + break def sweep_1d_linsweep(self, gate: str, @@ -890,7 +879,103 @@ def sweep_1d(self, dV (float): The voltage stepsize for all the gates. Default is set to 1 mV. """ - return None + # First, we set the initial voltage configuration specified + + if voltage_configuration is not None: + self.logger.info(f"setting voltage configuration: {voltage_configuration}") + self.set_voltage_configuration(voltage_configuration) + + # Then, we set the default dV and V bounds based on the config and setup_config files + + if dV is None: + dV = self.voltage_resolution + + if startV is None: + minV = self.voltage_sign * self.abs_max_gate_voltage + + if endV is None: + maxV = self.voltage_sign * self.abs_max_gate_voltage + + assert np.sign(endV) == self.voltage_sign, self.logger.error("Double check the sign of the gate voltage (maxV) for your given device.") + assert np.sign(startV) == self.voltage_sign or np.sign(startV) == 0, self.logger.error("Double check the sign of the gate voltage (minV) for your given device.") + + # Now, we collect the gate involved and set it to the initial voltage + + gates_involved = gate + + self.logger.info(f"setting {gates_involved} to {startV} V") + + self.set_voltage_configuration(gates_involved, startV) + + # First, we determine which gates are being set. + + gates = list(voltage_configuration.keys()) + + # Then, we assert that the sign of the voltage we wish to set agrees with the device we are testing + + for gate in gates: + + assert np.sign(voltage_configuration[gate]) == np.sign(self.voltage_sign) or np.sign(voltage_configuration[gate]) == 0, f"Check voltage sign on {gate}" + + # Now, we set up some lists to hold the voltage values + + gate_params = {} + start_vals = {} + step_sizes = {} + + # Now, we map the gate to the source and save the correspondance + + gate_to_source = {gate: instrument for source_name, instrument in self.voltage_sources.items() + for gate in self.voltage_source_names_check[source_name]} + + # Now, we gather the parameters to set + + for gate, target in voltage_configuration.items(): + + instrument = gate_to_source[gate] + param = getattr(instrument, gate) + + gate_params[gate] = param + start_vals[gate] = float(param.get()) + + step_param = getattr(instrument, f"{gate}_step", None) + step_sizes[gate] = step_param() if step_param else stepsize + + # Now, we determine the number of steps needed for each gate + + steps_needed = {} + + for gate, target in voltage_configuration.items(): + + dv = abs(target - start_vals[gate]) + steps_needed[gate] = math.ceil(dv / step_sizes[gate]) + + max_steps = max(steps_needed.values()) + + # Finally, we conduct the ramp + + for step in range(1, max_steps + 1): + + for gate, target in voltage_configuration.items(): + + start = start_vals[gate] + step_size = step_sizes[gate] + + direction = np.sign(target - start) + value = start + direction * step * step_size + + if direction > 0: + value = min(value, target) + else: + value = max(value, target) + + gate_params[gate].set(value) + + for instrument in self.voltage_sources.values(): + delay_param = getattr(instrument, "smooth_timestep", None) + if delay_param: + time.sleep(delay_param()) + break def check_break_conditions(self): From 80176c4d9f51cdfe48cd744c832a03012526b18f Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Mon, 6 Apr 2026 10:08:27 -0400 Subject: [PATCH 06/36] Refactoring --- src/gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui.py b/src/gui.py index c4f8339..d337cb9 100644 --- a/src/gui.py +++ b/src/gui.py @@ -35,7 +35,7 @@ def __init__(self): self.exp_thread = ExperimentThread() self.exp_thread.run() - test_func = lambda a, e: print(a) + test_func = lambda a, e: print(f"'{a}' from {threading.current_thread().name}") self.exp_thread.add_job(test_func, ("Hello World!",)) From 22a0ddf98aaafd18f12fdd5834a3f59fa02fa2f4 Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Mon, 6 Apr 2026 10:41:29 -0400 Subject: [PATCH 07/36] Further merging --- src/gui.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/gui.py b/src/gui.py index 7d20c6b..ac574ac 100644 --- a/src/gui.py +++ b/src/gui.py @@ -44,7 +44,7 @@ def __init__(self): # The below methods define the layout of the GUI - def start(self): + def root_page(self): """ The method that intialises the gui. As of now, it also defines the main page of the within itself. @@ -54,6 +54,7 @@ def start(self): """ self.header() + self.footer() # I tried putting these splitters into a separate function, but then the gui wouldn't start. @@ -125,11 +126,6 @@ def start(self): ui.timer(0.05, self.update_liveplot) ui.timer(0.25, self.update_experiment_progress_bar) - self.n = 0 - - self.footer() - - ui.run(port = 8081) def header(self): From 1e434ddaf24eb92a28386b7778f757f6e913b35e Mon Sep 17 00:00:00 2001 From: VanOschB Date: Mon, 6 Apr 2026 11:03:12 -0400 Subject: [PATCH 08/36] gui and core merge edits edits made to developing core to ensure the app still ran after merging gui into core. --- src/experiment_thread.py | 11 ++--------- src/gui.py | 18 ++++++++---------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/experiment_thread.py b/src/experiment_thread.py index 5b6b401..4192b6a 100644 --- a/src/experiment_thread.py +++ b/src/experiment_thread.py @@ -14,6 +14,7 @@ from queue import PriorityQueue from collections.abc import Callable + class ExperimentThread: @@ -73,12 +74,4 @@ def __thread_loop__(self): while self.abort_event.is_set(): self.job_queue.get() - self.job_queue.task_done() - -@app.on_startup -def run_experimental_thread(): - pass - -@app.on_startup -def __init__(): - pass \ No newline at end of file + self.job_queue.task_done() \ No newline at end of file diff --git a/src/gui.py b/src/gui.py index ac574ac..337066a 100644 --- a/src/gui.py +++ b/src/gui.py @@ -27,21 +27,19 @@ def __init__(self): ''' Creates an instance of the tuner gui - - params: - self: ''' - global _FirstPass - - # print(threading.current_thread().name) - global _gui_instances - _gui_instances.append(self) - self.start_time = 0 + self.start_time = time.time() self.readout = create_buffer_instance() self.readout.run() + self.experiment_thread = ExperimentThread() + self.experiment_thread.run() + + testjob = lambda a, event: print(f"{a} from {threading.current_thread().name}!") + self.experiment_thread.add_job(testjob, ("Hello",)) + # The below methods define the layout of the GUI def root_page(self): @@ -125,7 +123,6 @@ def root_page(self): ui.timer(0.05, self.update_liveplot) - ui.timer(0.25, self.update_experiment_progress_bar) def header(self): @@ -283,5 +280,6 @@ def on_abort(self): def on_shutdown(self): self.readout.join() + self.experiment_thread.join() From bad2f80f649baaabfffa21b36c2a0ccc946a367f Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Mon, 6 Apr 2026 16:19:14 -0400 Subject: [PATCH 09/36] Reworked the buffered readout to be compatible with qcodes. Added a dummy instrument for the live viewer --- .vscode/launch.json | 8 +++++--- configs/dummy_station.yaml | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 configs/dummy_station.yaml diff --git a/.vscode/launch.json b/.vscode/launch.json index 6b76b4f..a053f66 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,11 +5,13 @@ "version": "0.2.0", "configurations": [ { - "name": "Python Debugger: Current File", + "name": "Python Debugger: Gui Tuner", "type": "debugpy", "request": "launch", - "program": "${file}", - "console": "integratedTerminal" + "program": "${workspaceFolder}/src/main.py", + "cwd": "${workspaceFolder}/src", + "console": "integratedTerminal", + "justMyCode": true } ] } \ No newline at end of file diff --git a/configs/dummy_station.yaml b/configs/dummy_station.yaml new file mode 100644 index 0000000..4a312d9 --- /dev/null +++ b/configs/dummy_station.yaml @@ -0,0 +1,5 @@ +instruments: + DummyInst: + type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument + init: + name: DummyInst \ No newline at end of file From 9cfd6961dcee8647f87116795e1dc048952de990 Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Tue, 7 Apr 2026 08:08:41 -0400 Subject: [PATCH 10/36] Buffered readout changes to support qcodes.Station --- src/buffered_readout.py | 352 ++++++++++++++++++++++++++++++++++------ src/gui.py | 47 +++++- 2 files changed, 343 insertions(+), 56 deletions(-) diff --git a/src/buffered_readout.py b/src/buffered_readout.py index c0326c6..4609b0f 100644 --- a/src/buffered_readout.py +++ b/src/buffered_readout.py @@ -1,26 +1,62 @@ +''' +File: buffered_readout.py + +Author: Mason Daub (mjdaub@uwaterloo.ca) + +Provides an asynchronous readout buffer for a arbitrary number of qcodes instruments +and parameters. +''' import threading import time import numpy as np from typing import List, Tuple import random +from qcodes.station import Station +from qcodes.instrument import Instrument +from qcodes.parameters import Parameter +from collections.abc import Callable +from typing import Tuple, Dict, Any +from queue import Queue +from collections import deque +from enum import Enum __BufferExists__ = False __Instance__ = None -def create_buffer_instance(): +def create_buffer_instance(station : Station, station_lock : threading.Lock, time_func : Callable[[],Any] = time.monotonic): global __Instance__ if __Instance__ is None: - __Instance__ = buffered_readout() + __Instance__ = buffered_readout(station, station_lock, time_func) return __Instance__ +class inst_status(Enum): + queued = "Queued" + connected = "Connected" + failed = "Failed" + invalid = "Invalid" class buffered_readout: - def __init__(self): + def __init__(self, station : Station, station_lock : threading.Lock, time_func : Callable[[],Any] = time.monotonic): ''' A class to handle the asynchronous buffered readout of the SET current for - autotuning devices. + autotuning devices. Instruments can be added from the staton by calling the + method add_readout_instrument, where you specify the instrument, the parameters + you want to monitor, and an initialization callback (if desired) + + Parameters + ---------- + station : Station + The qcodes station with the config file loaded. Instruments will be loaded using this + + station_lock : threading.Lock + qcodes unfortunately writes to the station class during load_instrument, so we + need a lock to protect the station and make calls to it thread safe. + + time_func : Callable[[], Any] + A function with no arguments that returns some sort of time class. For now this is set to + time.monotonic to guarantee a monotonic increase in time. ''' global __BufferExists__ @@ -28,7 +64,7 @@ def __init__(self): __BufferExists__ = True - self.time_func = time.time + self.time_func = time_func self.BUFFER_SIZE = 1000 @@ -39,102 +75,316 @@ def __init__(self): It has a lock for multi-threaded operation which protects the buffer and its current index. ''' - self.buffer = [(float(0), float(-1.0))] * self.BUFFER_SIZE - self.buffer_index = 0 + self.buffers : Dict[str, deque] = {} self.buffer_lock = threading.Lock() self.THREAD_NAME = "BufferThread" - self.thread = threading.Thread(target = self.__thread_loop__, name = self.THREAD_NAME) + self.thread = threading.Thread(target = self._thread_loop, name = self.THREAD_NAME) self.running = False self.shutdown_event = threading.Event() - def __assert_correct_thread__(self): - #return True - assert threading.current_thread().name == self.THREAD_NAME, f"Error, buffer is being run in the thread '{threading.current_thread().name}'!" + self.station = station + self.station_lock = station_lock - def __open_instruments__(self): + self.instrument_load_queue = Queue() # no lock required, queue is thread safe - self.__assert_correct_thread__() + # A list storing a tuple of the instrument name and a list of the + # montitored paramters + self.reading_parameters : List[Tuple[str, List[str]]] = [] + self.instruments : Dict[str, Instrument] = {} - return - def __thread_loop__(self): + # Store the status of instruments. + self.instrument_status_lock = threading.Lock() + self.instrument_status : Dict[str, inst_status] = {} + + def _assert_correct_thread(self): + #return True + assert threading.current_thread().name == self.THREAD_NAME, f"Error, buffer is being run in the thread '{threading.current_thread().name}'!" + + def _thread_loop(self): - self.__assert_correct_thread__() + self._assert_correct_thread() - self.__open_instruments__() print(f"Starting the readout buffer loop in thread {self.THREAD_NAME}...") while not self.shutdown_event.is_set(): time.sleep(self.READING_TIME) - self.__read_instruments__() + self._add_queued_instruments() + self._read_instruments() + + self._close_instruments() + + print("Readout buffer thread stopping...") - print(f"Stopping the readout buffer thread...") return + def _add_queued_instruments(self): + ''' + Add all the instruments that may have been added to the queue + ''' + self._assert_correct_thread() + + # Add all of the insturments in the queue + while not self.instrument_load_queue.empty(): + inst : Instrument = None + # Try to acquire a lock on the station + if self.station_lock.acquire(timeout = 0.1): + try: + name, param_names, init_func, init_args = self.instrument_load_queue.get() + + # Make sure that we report if loading the instrument fails + try: + # log + print(f"Attempting to load instrument {name}") + inst = self.station.load_instrument(name) + + # Will throw an AttributeError if the params are bad + self._test_inst_has_parameters(inst, param_names) + except Exception as e: + print(e) # log + if inst is not None: + inst.close_all() + inst = None + self._set_instrument_status(name, inst_status.failed) + else: + # log + print(f"Instrument loaded, idn: {inst.get_idn()}") + self._set_instrument_status(name, inst_status.connected) + finally: + self.station_lock.release() + else: + break # Break from the loop if there is a timout or we cannot acquier the lock + + # Store the parameters and the instrument + # This is not done earlier so that we can release the lock on the station sooner. + if inst is not None: + if init_func is not None: + init_func(inst, init_args) + + # init the buffer for the parameter + if isinstance(param_names, str): + param_names = [param_names] + + self.reading_parameters.append((name, param_names)) + self.instruments[name] = inst + # Create a buffer for the parameter + with self.buffer_lock: + for param_name in param_names: + long_name = f"{name}.{param_name}" + self.buffers[long_name] = deque(maxlen = self.BUFFER_SIZE) - def __read_instruments__(self): + self.instrument_load_queue.task_done() + + def _test_inst_has_parameters(self, inst : Instrument, param_names : str | List[str]) -> bool: + ''' + Test if the instrument has the specified parameters. We just iterate through the parameters + and try to access is + ''' + if isinstance(param_names, str): + param_names = [param_names] + for param_name in param_names: + param : Parameter = getattr(inst, param_name) + if not param.gettable: + raise AttributeError(f"The specified parameter {param_name} of instrument {inst.name} is not getable.") + + def _close_instruments(self): + ''' + Only to be called by the buffer thread on shutdown. Acquires a lock on the station and + then closes all of the loaded instruments. + ''' + self._assert_correct_thread() + # I am not certain if closing the instrument modifies the station, + # but I think that it is likely. So we lock it to be safe + with self.station_lock: + for name, inst in self.instruments.items(): + try: + # Log + print(f"Closing instrument {name}") + inst.close_all() + except Exception as e: + print(e) # log + + def _read_instruments(self): value = random.Random(self.time_func()).random() # read the instrument! curr_time = self.time_func() - # Acquire a lock on the circular buffer and push it to the current index with self.buffer_lock: - self.buffer[self.buffer_index] = (value, curr_time) - self.buffer_index = (self.buffer_index + 1) % self.BUFFER_SIZE + for inst_name, param_names in self.reading_parameters: + for param_name in param_names: + param : Parameter = getattr(self.instruments[inst_name], param_name) + value = param() + curr_time = self.time_func() + self.buffers[f"{inst_name}.{param_name}"].append((value, curr_time)) + - def read_buffer(self, t_avg : float = 0.0, t_start : float = 0.0) -> float: + def read_buffer(self, var_name : str | List[str], t_avg : float = 0.0, t_stop : float = -1) -> Dict[str, float]: ''' + Sample one or more of the asynchronous instrument buffers, and average over a specified amount of time + + Parameters + ---------- + var_name: str | List[str] + A string or list of strings of the format '{inst_name}.{param_name}', specifying + the parameter you would like to read + t_avg: float = 0 + A float specifying the total amount of averaging time to return + t_stop: float = -1 + The absolute stop time of when the averging should stop at. The averageing will sample + the times t in [t_stop - t_avg, t_stop]. If t_stop is less than zero, the current time + is used. + Returns + ------- + retval : Dict[str, float] + Returns a dictonary of the var_name with its associated value + + Exceptions + ---------- + KeyError: + If the specified parameters are not in the dictionary of buffers, it will throw a KeyError exception + Exception: + If there are no data points in the perscribed averaging range ''' - if t_start <= 0.0: - t_start = time.time() + if t_stop <= 0.0: + t_stop = self.time_func() + + t_start = t_stop - t_avg + # Make var_name iterable if it is not already + if isinstance(var_name, str): + var_name = [var_name] + buffer_copies = {} # acquire a lock on the buffer for Readout, and then copy it with self.buffer_lock: - buffer_copy = self.buffer.copy() + for name in var_name: + buffer_copies[name] = List[self.buffers[name]] - # Sort according to the time stamp - buffer_copy.sort(key = lambda e: e[1]) + retval : Dict[str, float] = {} + + for name, data in buffer_copies.items(): + # If no averaging is specified, return last data point + if t_avg == 0.0: + val, timestamp = data[-1] + retval[name] = val + # otherwise, get all of the data points for the correct times + else: + sum = 0.0 + n : int = 0 + for i in range(len(data) - 1, 0, -1): + value, timestamp = data[i] + if timestamp >= t_start and timestamp <= t_stop: + sum += value + n += 1 + if n == 0: + raise Exception(f"Failed to read {name} over the interval {t_start}:{t_stop} (no samples)") + else: + retval[name] = sum / n - i = self.BUFFER_SIZE - 1 - values : List[float] = [] - t_stop = t_start - t_avg - while buffer_copy[i][1] >= t_stop: - timestamp = buffer_copy[i][1] + return retval - if timestamp <= t_start: - values.append(buffer_copy[i][0]) - return float(np.average(values)) - def get_buffer(self) -> Tuple | None: + def get_buffer(self, param_names : str | List[str] | None = None, timeout : float = 0.1) -> Dict[str, List[Tuple[float, float]]] | None: ''' Try to copy the buffer without blocking. If it fails to acquire the lock, it will return None. + + Parameters + ---------- + param_names: str | List[str] | None = None + A list of the parameter names that you wish to get a copy of the buffer for. + If None, then it will return a dictionary of all of the buffers. + + Return + ------ + retval : Dict[str, List[Tuple[float, float]]] | None + Will return a dictionary of the requested parameters with a copy of the buffer. If it fails to + obtain any buffers, it will return None. The buffer is a list of tuples of the value (0) and the timestamp (1) + + Exceptions + ---------- + KeyError + May throw a KeyError exception if a bad name is given ''' - - if self.buffer_lock.acquire(blocking = False): + buffer_copies = {} + if self.buffer_lock.acquire(blocking = False):#, timeout = timeout): try: - copy = self.buffer.copy() + if param_names is None: + param_names = self.buffers.keys() + elif isinstance(param_names, str): + param_names = [param_names] + for name in param_names: + buffer = list(self.buffers[name]) + buffer_copies[name] = buffer finally: self.buffer_lock.release() else: return None - # Next return only valid time stamps - copy.sort(key = lambda e : e[1]) - retval : List[float] = [] - timestamps : List[float] = [] - for value, timestamp in copy: - if timestamp >= 0.0: - retval.append(value) - timestamps.append(timestamp) - assert len(retval) == len(timestamps) - return (retval, timestamps) + return buffer_copies if len(buffer_copies) > 0 else None def run(self): if not self.running: self.thread.start() self.running = True + + def join(self): self.shutdown_event.set() self.thread.join() + + + def add_readout_instrument(self, name : str, param_names : str | List[str],\ + init_func : Callable[[Instrument, Tuple], None] | None = None,\ + init_args : Tuple = ()) -> None: + ''' + Add a readout instrument to the asynchonous buffer. Adds these parameters to a queue, + and the readout buffer thread will attempt to add the instrument in its control loop. + If the readout thread cannot load the instrument, it will get logged. Trying to access + an instrument that failed to add, or has not yet been added, will throw a KeyError exception. + + Parameters + ---------- + name: str + String for the name of the instrument in the qcodes station + + param_names: str | List[str] + A string or list of strings with the name of the qcodes parameter + to measure from this instrument + + init_func: Callable[[Instrument, Tuple], None] | None = None + A callback function of the form (Instrument, Tuple) -> None, called + after the instrument is loaded on the readout thread. The tuple is meant + to be used to pass any arguments required for initalization. + + init_args : Tuple = () + The arguments to get passed to the init function. By default, it is an empty tuple. + ''' + self.instrument_load_queue.put((name, param_names, init_func, init_args)) + self._set_instrument_status(name, inst_status.queued) + + def _set_instrument_status(self, instrument_name : str, status : inst_status): + with self.instrument_status_lock: + self.instrument_status[instrument_name] = status + + def get_instrument_status(self, instrument_name : str) -> inst_status: + ''' + Query the readout buffer for the status of an instrument. The buffer automatically + updates the status of each instrument. + + Paramters + --------- + instrument_name : str + The name of the instrument in the config file + + Returns + ------- + retval : inst_status + Returns a inst_status enumeration (which is just a string) to describe the + current status of the instrument. + ''' + with self.instrument_status_lock: + retval = self.instrument_status.get(instrument_name) + if retval is None: + return inst_status.invalid + return retval diff --git a/src/gui.py b/src/gui.py index 337066a..4990115 100644 --- a/src/gui.py +++ b/src/gui.py @@ -17,7 +17,36 @@ from buffered_readout import create_buffer_instance import time from experiment_thread import ExperimentThread - +from qcodes.station import Station +from qcodes.instrument_drivers.mock_instruments import DummyInstrument +from qcodes.parameters import Parameter +import random + +class RandomDummy(DummyInstrument): + ''' + A dummy instrument for testing the readout buffer + ''' + def __init__(self, + name: str = "dummy", + gates = ("dac1",), + **kwargs): + + super().__init__(name, gates, **kwargs) + + self.add_parameter("rand1", + parameter_class=Parameter, + initial_value=0, + label=f"Gate rand", + unit="V", + set_cmd=None, + get_cmd=lambda : random.Random(time.monotonic()).random()) + self.add_parameter("rand2", + parameter_class=Parameter, + initial_value=0, + label=f"Gate rand", + unit="V", + set_cmd=None, + get_cmd=lambda : random.Random(time.monotonic()).random()) class tuner_gui: @@ -29,11 +58,16 @@ def __init__(self): Creates an instance of the tuner gui ''' - self.start_time = time.time() + self.start_time = time.monotonic() + + self.station = Station(config_file = "../configs/dummy_station.yaml") + self.station_lock = threading.Lock() - self.readout = create_buffer_instance() + self.readout = create_buffer_instance(self.station, self.station_lock) self.readout.run() + self.readout.add_readout_instrument("DummyInst", ["rand1", "rand2"], lambda inst, args : inst.print_readable_snapshot()) + self.experiment_thread = ExperimentThread() self.experiment_thread.run() @@ -199,9 +233,12 @@ def update_liveplot(self): if retval is None: return else: - data, times = retval + keys = list(retval.keys()) + key = keys[0] + data = [retval[key][i][0] for i in range(len(retval[key]))] + times = [retval[key][i][1] for i in range(len(retval[key]))] - times_offset = np.array(times) - self.start_time + times_offset = np.array(times) - times[-1] self.line[0].set_ydata(data) self.line[0].set_xdata(times_offset) self.ax.set_xlim(min(times_offset), max(times_offset)) From 208dda10dc8883e30cc5d314f1dac239af079f79 Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Thu, 9 Apr 2026 15:38:02 -0400 Subject: [PATCH 11/36] Refactored the buffered readout. Now each instrument gets its own thread. --- src/buffered_readout.py | 555 +++++++++++++++++++++++++--------------- src/gui.py | 26 +- 2 files changed, 372 insertions(+), 209 deletions(-) diff --git a/src/buffered_readout.py b/src/buffered_readout.py index 4609b0f..05e4583 100644 --- a/src/buffered_readout.py +++ b/src/buffered_readout.py @@ -15,30 +15,257 @@ from qcodes.instrument import Instrument from qcodes.parameters import Parameter from collections.abc import Callable -from typing import Tuple, Dict, Any +from typing import Tuple, Dict, Any, Literal, Protocol, Optional, Deque from queue import Queue from collections import deque from enum import Enum +import re +import sys, os __BufferExists__ = False __Instance__ = None -def create_buffer_instance(station : Station, station_lock : threading.Lock, time_func : Callable[[],Any] = time.monotonic): +def create_buffer_instance(station : Station, station_lock : threading.Lock): global __Instance__ if __Instance__ is None: - __Instance__ = buffered_readout(station, station_lock, time_func) + __Instance__ = buffered_readout(station, station_lock) return __Instance__ +def make_list(strings : str | List[str] | None) -> List[str] | None: + if isinstance(strings, str): + return [strings] + else: + return strings + class inst_status(Enum): queued = "Queued" connected = "Connected" failed = "Failed" invalid = "Invalid" +class InstrumentCallback(Protocol): + def __call__(self, instrument: Instrument, *args: Any) -> None: + ... + +class buffer_thread: + def __init__(self, thread_name : str,\ + instrument_name : str,\ + station : Station,\ + station_lock : threading.Lock,\ + global_shutdown: threading.Event,\ + init_func : Optional[InstrumentCallback] = None, + *init_args : Any): + + self.parameters_private : List[str] = [] + self.parameters_public : List[str] = [] + self.parameters_lock = threading.Lock() + self.param_queue = Queue() + + self.BUFFER_SIZE = 1000 + self.buffer : Dict[str, Deque[Tuple[float, float]]] = {} + self.buffer_lock = threading.Lock() + self.measure_time = 0.01 + + self.thread_name = thread_name + self.thread = threading.Thread(target = self._worker,\ + name = self.thread_name,\ + args = (instrument_name, station, station_lock, init_func, init_args)) + + self.instrument : Instrument = None # DO NOT ACCESS EXTERNALLY + + self.shutdown_signal = threading.Event() + self.global_shutdown = global_shutdown + + # heartbeat signal for the watchdog timer + self.heartbeat : float = -1.0 + self.heartbeat_lock = threading.Lock() + + self.timefunc : Callable[[],float] = time.monotonic + + self.status : str = "Not started" + self.status_lock = threading.Lock() + + def start(self): + self.thread.start() + + def stop(self): + status = self.get_status() + if status == "Running": + # log + print(f"Stopping the instrument thread {self.thread_name}") + self.shutdown_signal.set() + self.thread.join() + + def _update_status(self, status_string : str): + ''' + Update the status of the thread. Private, do not call (thread safe). + ''' + with self.status_lock: + self.status = status_string + def get_status(self) -> str: + ''' + Get current status of the instrument buffer thread (thread safe). + ''' + with self.status_lock: + return self.status + def get_heartbeat(self) -> float: + with self.heartbeat_lock: + return self.heartbeat + + def _worker(self, instrument_name : str, station : Station,\ + station_lock : threading.Lock,\ + init_func : Optional[InstrumentCallback] = None,\ + *init_args : Any): + ''' + The worker function that controls the asnychonous buffering of a single instrument. (Not thread safe) + + Parameters + ---------- + instrument_name : str + Name of the instrument in the station configuration file. This is the instrument that the worker + will attempt to load onto this thread. + station : Station + The station to load the instument from. This will cause the config file to get reloaded, so it + must have a lock passed with it + station_lock : threading.Lock + The lock to protect the station. + init_func : Optional[InstrumentCallback] = None + An InstrumentCallback function that is called after loading the instrument. Allows for settings to be + set from the worker thread. No init function by default + *init_args : Any + An args list that is passed to the initialization callback. + ''' + # Start the first heartbeat for the watchdog + with self.heartbeat_lock: + self.heartbeat = self.timefunc() + self._update_status("Initializing") + # Try to load the instrument + try: + with station_lock: + self.instrument = station.load_instrument(instrument_name) + if not init_func is None: + # if there is an incorrect number of arguments, init_func will raise a type error + # There may be additional exceptions raised within init_func if they are not handled + init_func(self.instrument, *init_args) + except Exception as e: + # log the error + print(f"Worker exception for {instrument_name} with initialization function {init_func} and args {init_args}.\n {e}") + self.shutdown_signal.set() # Signal to the watchdog that the thread shut down instead of hung + self._update_status("Failed to initialize") + return # kill the thread. + + # Start the readout loop + self._update_status("Running") + tprev = self.timefunc() + while not self.shutdown_signal.is_set() and not self.global_shutdown.is_set(): + + self._process_queue() + self._read_parameters() + tnow : float + with self.heartbeat_lock: + tnow = self.timefunc() + self.heartbeat = tnow + delta = tnow - tprev + sleep_time = self.measure_time - delta + if sleep_time > 0.001: + time.sleep(self.measure_time) + tprev = self.timefunc() + + # Clear the monitored parameters + with self.parameters_lock: + self.parameters_private = [] + self.parameters_public = [] + + # Shutdown the thread. Set status to stopped now in case of exception on close + self._update_status("Stopped") + if self.instrument is not None: + self.instrument.close_all() + + def _read_parameters(self): + for param_name in self.parameters_private: + param : Parameter + try: + param = getattr(self.instrument, param_name) + except Exception as e: + print(f"Exception while reading parameter: {e}") + else: + value = param() + timestamp = self.timefunc() + + self.buffer[param_name].append((value, timestamp)) + + def _process_queue(self): + if not self.param_queue.empty(): + op, name = self.param_queue.get() + name_in_params = name in self.parameters_private + if op == "add" and not name_in_params: + try: + param = getattr(self.instrument, name) + if not param.gettable: + raise Exception(f"Parameter {self.instrument.name}.{name} is not gettable!") + except KeyError as e: + print(f"Key error for parameter {self.instrument.name}.{name}! {e}") + except Exception as e: + # log the exceptions (keyError from getattr, or the manual exception) + print(e) + else: + self.parameters_private.append(name) # Add the name to the list of monitored params + # add a new buffer entry if there is not already one. + with self.buffer_lock: + if not name in self.buffer: + self.buffer[name] = deque(maxlen =self.BUFFER_SIZE) + + elif op == 'add' and name_in_params: + # log + print(f"Parameter {self.instrument.name}.{name} is already being monitored.") + elif op == 'remove' and name_in_params: + self.parameters_private.remove(name) + print(f"Parameter {self.instrument.name}.{name} is no longer being monitored.") + elif op == 'callback': + # in this case name is actually a lambda function. + try: + name(self.instrument) + except Exception as e: + print(f"Exception with callback function {name}: {e}") + + # Update the copy + with self.parameters_lock: + self.parameters_public = self.parameters_private.copy() + + + + def queue_parameters(self, param_names : List[str], operation : Literal["add", "remove"] = "add") -> int: + ''' + Communicates to the thread that you would like to try to add or remove a parameter from the list + of measured parameters. + + Parameters + --------- + param_names : str | List[str] + A name or list of parameter names belonging to this instrument to add. + + operation: "add" or "remove" + Specifies whether to add or remove the requested parameter from the list of measured parameters + + Returns + ------- + Returns the number of parameters that are attempting to be added. + ''' + n = 0 + with self.parameters_lock: + for param_name in param_names: + if not param_name in self.parameters_public: + self.param_queue.put((operation, param_name)) + n += 1 + return n + + def queue_instrument_callback(self, callback : InstrumentCallback, *args): + self.param_queue.put(("callback", lambda inst: callback(inst, *args))) + class buffered_readout: - def __init__(self, station : Station, station_lock : threading.Lock, time_func : Callable[[],Any] = time.monotonic): + def __init__(self, station : Station, station_lock : threading.Lock): ''' A class to handle the asynchronous buffered readout of the SET current for autotuning devices. Instruments can be added from the staton by calling the @@ -64,160 +291,16 @@ def __init__(self, station : Station, station_lock : threading.Lock, time_func : __BufferExists__ = True - self.time_func = time_func + self.instrument_threads : Dict[str, buffer_thread] = {} - self.BUFFER_SIZE = 1000 - - self.READING_TIME = 0.01 - - ''' - Define the circular buffer for storing the stream of data - It has a lock for multi-threaded operation which protects the - buffer and its current index. - ''' - self.buffers : Dict[str, deque] = {} - self.buffer_lock = threading.Lock() - - self.THREAD_NAME = "BufferThread" - self.thread = threading.Thread(target = self._thread_loop, name = self.THREAD_NAME) - self.running = False - self.shutdown_event = threading.Event() + self.heartbeats : Dict[str, float] = {} self.station = station self.station_lock = station_lock - self.instrument_load_queue = Queue() # no lock required, queue is thread safe + self.global_shutdown = threading.Event() # A global shutdown signal for all child threads. - # A list storing a tuple of the instrument name and a list of the - # montitored paramters - self.reading_parameters : List[Tuple[str, List[str]]] = [] - self.instruments : Dict[str, Instrument] = {} - - # Store the status of instruments. - self.instrument_status_lock = threading.Lock() - self.instrument_status : Dict[str, inst_status] = {} - - def _assert_correct_thread(self): - #return True - assert threading.current_thread().name == self.THREAD_NAME, f"Error, buffer is being run in the thread '{threading.current_thread().name}'!" - - def _thread_loop(self): - - self._assert_correct_thread() - - - print(f"Starting the readout buffer loop in thread {self.THREAD_NAME}...") - - while not self.shutdown_event.is_set(): - - time.sleep(self.READING_TIME) - self._add_queued_instruments() - self._read_instruments() - - self._close_instruments() - - print("Readout buffer thread stopping...") - - return - def _add_queued_instruments(self): - ''' - Add all the instruments that may have been added to the queue - ''' - self._assert_correct_thread() - - # Add all of the insturments in the queue - while not self.instrument_load_queue.empty(): - inst : Instrument = None - # Try to acquire a lock on the station - if self.station_lock.acquire(timeout = 0.1): - try: - name, param_names, init_func, init_args = self.instrument_load_queue.get() - - # Make sure that we report if loading the instrument fails - try: - # log - print(f"Attempting to load instrument {name}") - inst = self.station.load_instrument(name) - - # Will throw an AttributeError if the params are bad - self._test_inst_has_parameters(inst, param_names) - except Exception as e: - print(e) # log - if inst is not None: - inst.close_all() - inst = None - self._set_instrument_status(name, inst_status.failed) - else: - # log - print(f"Instrument loaded, idn: {inst.get_idn()}") - self._set_instrument_status(name, inst_status.connected) - finally: - self.station_lock.release() - else: - break # Break from the loop if there is a timout or we cannot acquier the lock - - # Store the parameters and the instrument - # This is not done earlier so that we can release the lock on the station sooner. - if inst is not None: - if init_func is not None: - init_func(inst, init_args) - - # init the buffer for the parameter - if isinstance(param_names, str): - param_names = [param_names] - - self.reading_parameters.append((name, param_names)) - self.instruments[name] = inst - # Create a buffer for the parameter - with self.buffer_lock: - for param_name in param_names: - long_name = f"{name}.{param_name}" - self.buffers[long_name] = deque(maxlen = self.BUFFER_SIZE) - - self.instrument_load_queue.task_done() - - def _test_inst_has_parameters(self, inst : Instrument, param_names : str | List[str]) -> bool: - ''' - Test if the instrument has the specified parameters. We just iterate through the parameters - and try to access is - ''' - if isinstance(param_names, str): - param_names = [param_names] - for param_name in param_names: - param : Parameter = getattr(inst, param_name) - if not param.gettable: - raise AttributeError(f"The specified parameter {param_name} of instrument {inst.name} is not getable.") - - def _close_instruments(self): - ''' - Only to be called by the buffer thread on shutdown. Acquires a lock on the station and - then closes all of the loaded instruments. - ''' - self._assert_correct_thread() - # I am not certain if closing the instrument modifies the station, - # but I think that it is likely. So we lock it to be safe - with self.station_lock: - for name, inst in self.instruments.items(): - try: - # Log - print(f"Closing instrument {name}") - inst.close_all() - except Exception as e: - print(e) # log - - def _read_instruments(self): - value = random.Random(self.time_func()).random() # read the instrument! - curr_time = self.time_func() - - # Acquire a lock on the circular buffer and push it to the current index - with self.buffer_lock: - for inst_name, param_names in self.reading_parameters: - for param_name in param_names: - param : Parameter = getattr(self.instruments[inst_name], param_name) - value = param() - curr_time = self.time_func() - self.buffers[f"{inst_name}.{param_name}"].append((value, curr_time)) - + self.monitored_parameters : List[str] = [] def read_buffer(self, var_name : str | List[str], t_avg : float = 0.0, t_stop : float = -1) -> Dict[str, float]: ''' @@ -251,15 +334,8 @@ def read_buffer(self, var_name : str | List[str], t_avg : float = 0.0, t_stop : t_stop = self.time_func() t_start = t_stop - t_avg - # Make var_name iterable if it is not already - if isinstance(var_name, str): - var_name = [var_name] - - buffer_copies = {} - # acquire a lock on the buffer for Readout, and then copy it - with self.buffer_lock: - for name in var_name: - buffer_copies[name] = List[self.buffers[name]] + + buffer_copies = self.get_buffer(var_name, blocking = True) retval : Dict[str, float] = {} @@ -284,10 +360,9 @@ def read_buffer(self, var_name : str | List[str], t_avg : float = 0.0, t_stop : return retval - def get_buffer(self, param_names : str | List[str] | None = None, timeout : float = 0.1) -> Dict[str, List[Tuple[float, float]]] | None: + def get_buffer(self, param_names : str | List[str] | None = None, blocking : bool= False, timeout : float = 0.1) -> Dict[str, List[Tuple[float, float]]] | None: ''' - Try to copy the buffer without blocking. If it fails to acquire the lock, - it will return None. + Try to copy a buffer for a parameter with optional blocking Parameters ---------- @@ -305,38 +380,44 @@ def get_buffer(self, param_names : str | List[str] | None = None, timeout : floa ---------- KeyError May throw a KeyError exception if a bad name is given + ValueError + This will get thrown if param_names are not able to be parsed ''' buffer_copies = {} - if self.buffer_lock.acquire(blocking = False):#, timeout = timeout): - try: - if param_names is None: - param_names = self.buffers.keys() - elif isinstance(param_names, str): - param_names = [param_names] - for name in param_names: - buffer = list(self.buffers[name]) - buffer_copies[name] = buffer - finally: - self.buffer_lock.release() - else: - return None + if param_names is None: + param_names = self.monitored_parameters + param_names = make_list(param_names) + for param_name in param_names: + match = re.match(r'^(\w+)\.(\w+)$', param_name) + inst_name : str + param_name : str + if match: + inst_name, param_short = match.groups() + else: + # log + raise ValueError(f"Read_Buffer: Failed to parse parameter {param_name}") + + thread = self.instrument_threads.get(inst_name) + if not thread is None: + if thread.buffer_lock.acquire(blocking = blocking): + # copy the buffer + # May throw a KeyError exception + try: + buffer_copies[param_name] = list(thread.buffer[param_short]) + finally: + thread.buffer_lock.release() return buffer_copies if len(buffer_copies) > 0 else None - def run(self): - if not self.running: - self.thread.start() - self.running = True - - - def join(self): - self.shutdown_event.set() - self.thread.join() + def shutdown_instruments(self): + self.global_shutdown.set() + for inst_thread in self.instrument_threads.values(): + inst_thread.stop() - def add_readout_instrument(self, name : str, param_names : str | List[str],\ - init_func : Callable[[Instrument, Tuple], None] | None = None,\ - init_args : Tuple = ()) -> None: + def add_instrument(self, name : str, param_names : str | List[str] | None = None,\ + init_func : Optional[InstrumentCallback] = None,\ + *init_args : Any) -> None: ''' Add a readout instrument to the asynchonous buffer. Adds these parameters to a queue, and the readout buffer thread will attempt to add the instrument in its control loop. @@ -348,7 +429,7 @@ def add_readout_instrument(self, name : str, param_names : str | List[str],\ name: str String for the name of the instrument in the qcodes station - param_names: str | List[str] + param_names: str | List[str] | None = None A string or list of strings with the name of the qcodes parameter to measure from this instrument @@ -360,14 +441,50 @@ def add_readout_instrument(self, name : str, param_names : str | List[str],\ init_args : Tuple = () The arguments to get passed to the init function. By default, it is an empty tuple. ''' - self.instrument_load_queue.put((name, param_names, init_func, init_args)) - self._set_instrument_status(name, inst_status.queued) - - def _set_instrument_status(self, instrument_name : str, status : inst_status): - with self.instrument_status_lock: - self.instrument_status[instrument_name] = status + param_names = make_list(param_names) # Make it iterable + + # Check to see if the instrument already exists + instr_thread = self.instrument_threads.get(name) + if instr_thread is None: + self.heartbeats[name] = time.monotonic() # Create the first heartbeat + + self.instrument_threads[name] = buffer_thread(f"{name}Thread", name,\ + self.station,\ + self.station_lock,\ + self.global_shutdown,\ + init_func, init_args) + if param_names is not None: + self.instrument_threads[name].queue_parameters(param_names) + + self.instrument_threads[name].start() + + def remove_instrument(self, name : str): + ''' + Stop a specific instrument and close it. This will not delete any of the data that is still buffered. + ''' + if name in self.instrument_threads: + thread = self.instrument_threads[name] + thread.stop() + + def add_parameter(self, inst_name : str, params : str | List[str]): + inst = self.instrument_threads.get(inst_name) + if not inst is None: + params = make_list(params) + inst.queue_parameters(params) + else: + # log + print(f"Cannot add parameter to instrument {inst_name}: it does not exist!") + + def remove_parameter(self, inst_name : str, params : str | List[str]): + inst = self.instrument_threads.get(inst_name) + if not inst is None: + params = make_list(params) + inst.queue_parameters(params, 'remove') + else: + # log + print(f"Cannot remove parameter from instrument {inst_name}: it does not exist!") - def get_instrument_status(self, instrument_name : str) -> inst_status: + def get_instrument_status(self, instrument_name : str) -> str: ''' Query the readout buffer for the status of an instrument. The buffer automatically updates the status of each instrument. @@ -383,8 +500,42 @@ def get_instrument_status(self, instrument_name : str) -> inst_status: Returns a inst_status enumeration (which is just a string) to describe the current status of the instrument. ''' - with self.instrument_status_lock: - retval = self.instrument_status.get(instrument_name) - if retval is None: - return inst_status.invalid - return retval + inst_thread : buffer_thread = self.instrument_threads.get(instrument_name) + if not inst_thread is None: + return inst_thread.get_status() + else: + return "DNE" + + def watchdog(self) -> bool: + ''' + This is the watchdog timer callback for the buffered_readout object. It + keeps track of the status of all of the threads and checks if they still have a heartbeat. + + Returns + ------- + If the readout buffer is healthy, it returns true. If the readout buffer is not healthy, it will return + false to trigger a reset. + + ''' + WATCHDOG_TIME = 30 # time between heartbeats before death is declared. + self.monitored_parameters.clear() + + for name, thread in self.instrument_threads.items(): + heartbeat = thread.get_heartbeat() + current_time = time.monotonic() + thread_status = thread.get_status() + + # if the thread takes longer than the watchdog time, it's dead. + alive = True + if current_time - heartbeat > WATCHDOG_TIME: + alive = False + + if thread_status == 'Running' and alive: + with thread.parameters_lock: + for param in thread.parameters_public: + self.monitored_parameters.append(f"{name}.{param}") + + elif not alive and (thread_status == 'Running' or thread_status == 'Initializing'): + print(f"Instrument thread {name} is dead with status {thread_status}") + #return False + return True diff --git a/src/gui.py b/src/gui.py index 4990115..2673428 100644 --- a/src/gui.py +++ b/src/gui.py @@ -21,6 +21,7 @@ from qcodes.instrument_drivers.mock_instruments import DummyInstrument from qcodes.parameters import Parameter import random +import os, sys class RandomDummy(DummyInstrument): ''' @@ -64,9 +65,9 @@ def __init__(self): self.station_lock = threading.Lock() self.readout = create_buffer_instance(self.station, self.station_lock) - self.readout.run() + #self.readout.run() - self.readout.add_readout_instrument("DummyInst", ["rand1", "rand2"], lambda inst, args : inst.print_readable_snapshot()) + self.readout.add_instrument("DummyInst", ["rand1", "rand2"], lambda inst, *args : inst.print_readable_snapshot()) self.experiment_thread = ExperimentThread() self.experiment_thread.run() @@ -74,6 +75,8 @@ def __init__(self): testjob = lambda a, event: print(f"{a} from {threading.current_thread().name}!") self.experiment_thread.add_job(testjob, ("Hello",)) + self.abort_signal = threading.Event() + # The below methods define the layout of the GUI def root_page(self): @@ -157,6 +160,7 @@ def root_page(self): ui.timer(0.05, self.update_liveplot) + ui.timer(0.5, self.watchdog_timer) def header(self): @@ -235,6 +239,7 @@ def update_liveplot(self): else: keys = list(retval.keys()) key = keys[0] + data = retval[key] data = [retval[key][i][0] for i in range(len(retval[key]))] times = [retval[key][i][1] for i in range(len(retval[key]))] @@ -311,12 +316,19 @@ def on_autotune(self): pass + def watchdog_timer(self): + if not self.readout.watchdog(): + # Trigger a reset + print("Readout buffer watchdog detected a problem. Triggering a reset.") + app.stop() + python = sys.executable # path to the Python interpreter + os.execv(python, [python] + sys.argv) + def on_abort(self): ui.notify('Aborting...') + self.abort_signal.set() def on_shutdown(self): - - self.readout.join() - self.experiment_thread.join() - - + self.abort_signal.set() # Abort any currently running experiments. + self.readout.shutdown_instruments() + self.experiment_thread.join() \ No newline at end of file From d6df47d2e574e448a8ef28348c4d095f825e912e Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Fri, 10 Apr 2026 13:04:56 -0400 Subject: [PATCH 12/36] Added auto live plotting for multiple parameteres. --- configs/dummy_station.yaml | 4 +-- src/buffered_readout.py | 2 +- src/gui.py | 52 ++++++++++++++++++++++++++------------ 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/configs/dummy_station.yaml b/configs/dummy_station.yaml index 4a312d9..92d8037 100644 --- a/configs/dummy_station.yaml +++ b/configs/dummy_station.yaml @@ -1,5 +1,5 @@ instruments: DummyInst: type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument - init: - name: DummyInst \ No newline at end of file + DummyInst2: + type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument \ No newline at end of file diff --git a/src/buffered_readout.py b/src/buffered_readout.py index 05e4583..e208882 100644 --- a/src/buffered_readout.py +++ b/src/buffered_readout.py @@ -517,7 +517,7 @@ def watchdog(self) -> bool: false to trigger a reset. ''' - WATCHDOG_TIME = 30 # time between heartbeats before death is declared. + WATCHDOG_TIME = 60 # time between heartbeats before death is declared. self.monitored_parameters.clear() for name, thread in self.instrument_threads.items(): diff --git a/src/gui.py b/src/gui.py index 2673428..4315dc5 100644 --- a/src/gui.py +++ b/src/gui.py @@ -10,6 +10,7 @@ import numpy as np import matplotlib.pyplot as plt +from matplotlib.lines import Line2D from nicegui import ui, app import os import threading @@ -68,6 +69,7 @@ def __init__(self): #self.readout.run() self.readout.add_instrument("DummyInst", ["rand1", "rand2"], lambda inst, *args : inst.print_readable_snapshot()) + self.readout.add_instrument("DummyInst2", ["rand1", "rand2"], lambda inst, *args : inst.print_readable_snapshot()) self.experiment_thread = ExperimentThread() self.experiment_thread.run() @@ -219,36 +221,54 @@ def live_plot_window(self): """ - self.liveplot = ui.matplotlib(figsize = (30,20)) + self.liveplot = ui.matplotlib(figsize = (8,6)) fig = self.liveplot.figure self.ax = fig.subplots(1,1) self.ax.set_xlabel("Time (s)") self.ax.set_ylabel('Signal (V)') xs = np.linspace(-1, 1) - self.line = self.ax.plot(xs, np.sin(xs)) - self.ax.set_xlabel('time (s)', fontsize = 75) - self.ax.set_ylabel('Current (A)', fontsize = 75) - self.ax.tick_params(labelsize = 50) + self.lines = self.ax.plot(xs, np.sin(xs)) + self.ax.set_xlabel('time (s)', fontsize = 16) + self.ax.set_ylabel('Current (A)', fontsize = 16) + self.ax.tick_params(labelsize = 12) + fig.tight_layout() self.liveplot.update() def update_liveplot(self): + colors = ['tab:blue', 'tab:red', 'tab:orange', 'tab:purple', 'tab:green'] + linestyles = ['-', '--', '-.', ':'] + retval = self.readout.get_buffer() if retval is None: return else: keys = list(retval.keys()) - key = keys[0] - data = retval[key] - data = [retval[key][i][0] for i in range(len(retval[key]))] - times = [retval[key][i][1] for i in range(len(retval[key]))] - - times_offset = np.array(times) - times[-1] - self.line[0].set_ydata(data) - self.line[0].set_xdata(times_offset) - self.ax.set_xlim(min(times_offset), max(times_offset)) - self.ax.set_ylim(-0.5, 1.5) - + num_keys = len(keys) + if num_keys != len(self.lines): + # Clear the axis. + for line in self.lines: + line.remove() + # add the lines back in + for j in range(num_keys): + col = colors[j % len(colors)] + ls = linestyles[j // len(colors)] + self.ax.add_line(Line2D([0],[0], lw = 2, color = col, linestyle=ls)) + self.lines = self.ax.get_lines() + + for j in range(num_keys): + key = keys[j] + data = retval[key] + data = [retval[key][i][0] for i in range(len(retval[key]))] + times = [retval[key][i][1] for i in range(len(retval[key]))] + + times_offset = np.array(times) - times[-1] + self.lines[j].set_ydata(data) + self.lines[j].set_xdata(times_offset) + self.ax.set_xlim(min(times_offset), max(times_offset)) + self.ax.set_ylim(-0.5, 1.5) + + self.ax.legend(self.lines, keys, ) self.liveplot.update() ui.update() From 63316c13ba4bc49b511ea804f0719d865ad8226c Mon Sep 17 00:00:00 2001 From: Mason Daub Date: Tue, 14 Apr 2026 13:36:42 -0400 Subject: [PATCH 13/36] Refactored the readout buffer to control all instruments. Added logging. --- src/gui.py | 23 +- ...fered_readout.py => instrument_handler.py} | 430 +++++++++++++----- src/main.py | 11 +- src/test.py | 10 - src/tunerlog.py | 148 ++++++ 5 files changed, 480 insertions(+), 142 deletions(-) rename src/{buffered_readout.py => instrument_handler.py} (54%) delete mode 100644 src/test.py create mode 100644 src/tunerlog.py diff --git a/src/gui.py b/src/gui.py index 4315dc5..32f9c29 100644 --- a/src/gui.py +++ b/src/gui.py @@ -15,7 +15,7 @@ import os import threading import time -from buffered_readout import create_buffer_instance +from instrument_handler import create_buffer_instance import time from experiment_thread import ExperimentThread from qcodes.station import Station @@ -23,6 +23,7 @@ from qcodes.parameters import Parameter import random import os, sys +from tunerlog import TunerLog class RandomDummy(DummyInstrument): ''' @@ -68,8 +69,10 @@ def __init__(self): self.readout = create_buffer_instance(self.station, self.station_lock) #self.readout.run() - self.readout.add_instrument("DummyInst", ["rand1", "rand2"], lambda inst, *args : inst.print_readable_snapshot()) - self.readout.add_instrument("DummyInst2", ["rand1", "rand2"], lambda inst, *args : inst.print_readable_snapshot()) + self.readout.add_instrument("DummyInst") + self.readout.add_instrument("DummyInst2") + + self.readout.monitor_parameter("DummyInst", ["rand1", "rand2"]) self.experiment_thread = ExperimentThread() self.experiment_thread.run() @@ -78,6 +81,7 @@ def __init__(self): self.experiment_thread.add_job(testjob, ("Hello",)) self.abort_signal = threading.Event() + self.logger = TunerLog("TunerGUI") # The below methods define the layout of the GUI @@ -158,8 +162,9 @@ def root_page(self): with splitter2.after: - ui.label('Logger Information').classes('ml-2') - + self.ui_log = ui.log() + self.logger.add_ui_handler(self.ui_log) + self.logger.info("Added NiceGUI UI handler to logger.") ui.timer(0.05, self.update_liveplot) ui.timer(0.5, self.watchdog_timer) @@ -339,13 +344,13 @@ def on_autotune(self): def watchdog_timer(self): if not self.readout.watchdog(): # Trigger a reset - print("Readout buffer watchdog detected a problem. Triggering a reset.") - app.stop() - python = sys.executable # path to the Python interpreter - os.execv(python, [python] + sys.argv) + self.logger.error("Readout buffer watchdog detected a problem. Triggering a reset.") + #python = sys.executable # path to the Python interpreter + #os.execv(python, [python] + sys.argv) def on_abort(self): ui.notify('Aborting...') + self.logger.warning("Experiment abort signal sent") self.abort_signal.set() def on_shutdown(self): diff --git a/src/buffered_readout.py b/src/instrument_handler.py similarity index 54% rename from src/buffered_readout.py rename to src/instrument_handler.py index e208882..163d806 100644 --- a/src/buffered_readout.py +++ b/src/instrument_handler.py @@ -10,7 +10,6 @@ import time import numpy as np from typing import List, Tuple -import random from qcodes.station import Station from qcodes.instrument import Instrument from qcodes.parameters import Parameter @@ -18,22 +17,24 @@ from typing import Tuple, Dict, Any, Literal, Protocol, Optional, Deque from queue import Queue from collections import deque +from dataclasses import dataclass from enum import Enum +from tunerlog import TunerLog import re -import sys, os -__BufferExists__ = False -__Instance__ = None +_BufferExists = False +_Instance = None +logger = TunerLog("Instr. Control") def create_buffer_instance(station : Station, station_lock : threading.Lock): - global __Instance__ - if __Instance__ is None: - __Instance__ = buffered_readout(station, station_lock) + global _Instance, logger + if _Instance is None: + _Instance = instrument_handler(station, station_lock) - return __Instance__ + return _Instance -def make_list(strings : str | List[str] | None) -> List[str] | None: +def make_list(strings : str | List[str] | Literal['all']) -> List[str]: if isinstance(strings, str): return [strings] else: @@ -46,22 +47,71 @@ class inst_status(Enum): invalid = "Invalid" class InstrumentCallback(Protocol): - def __call__(self, instrument: Instrument, *args: Any) -> None: + def __call__(self, instrument: Instrument, *args: Any) -> Any: ... -class buffer_thread: +class TunerFuture: + def __init__(self): + self._done_event = threading.Event() + self._result : Any = None + self._exception : Exception | None = None + + def set_result(self, result : Any): + self._result = result + self._done_event.set() + + def set_exception(self, e : Exception): + self._exception = e + self._done_event.set() + + def result(self, timeout : float | None = None): + if self._done_event.wait(timeout): + if self._exception is not None: + raise self._exception + return self._result + else: + raise TimeoutError("Future timed out while waiting for result") + +@dataclass +class instrument_job: + future : TunerFuture + when : float + type : str + +class instrument_callback_job(instrument_job): + def __init__(self, future : TunerFuture, callback : InstrumentCallback, *args, when : float = -1): + self.callback : Callable[[Instrument], Any] = lambda inst: callback(inst, args) + super().__init__(future, when, "instrument_callback") + +class get_parameter_job(instrument_job): + def __init__(self, future : TunerFuture, params : List[str], when : float = -1): + super().__init__(future, when, "get parameter job") + self.parameters = params + +class set_parameter_job(instrument_job): + def __init__(self, future : TunerFuture, set_vals : Dict[str, Any], when : float = -1): + super().__init__(future, when, "set parameter job") + self.set_vals = set_vals + +class change_monitor_status_job(instrument_job): + def __init__(self, future : TunerFuture, params : List[str], add : bool = True, when : float = -1): + super().__init__(future, when, "modify monitor status job") + self.parameters = params + self.add_or_remove = add + +class instrument_thread: def __init__(self, thread_name : str,\ - instrument_name : str,\ + instrument_name: str,\ station : Station,\ station_lock : threading.Lock,\ global_shutdown: threading.Event,\ - init_func : Optional[InstrumentCallback] = None, + init_func : Optional[InstrumentCallback] = None,\ *init_args : Any): self.parameters_private : List[str] = [] self.parameters_public : List[str] = [] self.parameters_lock = threading.Lock() - self.param_queue = Queue() + self.job_queue : Queue[instrument_job] = Queue() self.BUFFER_SIZE = 1000 self.buffer : Dict[str, Deque[Tuple[float, float]]] = {} @@ -73,7 +123,7 @@ def __init__(self, thread_name : str,\ name = self.thread_name,\ args = (instrument_name, station, station_lock, init_func, init_args)) - self.instrument : Instrument = None # DO NOT ACCESS EXTERNALLY + self.instrument : Instrument # DO NOT ACCESS EXTERNALLY self.shutdown_signal = threading.Event() self.global_shutdown = global_shutdown @@ -94,9 +144,15 @@ def stop(self): status = self.get_status() if status == "Running": # log - print(f"Stopping the instrument thread {self.thread_name}") + logger.debug(f"Joining the instrument thread {self.thread_name}") self.shutdown_signal.set() - self.thread.join() + try: + self.thread.join(10) + except TimeoutError as e: + logger.exception("Joining thread '%s' timed out!", self.thread_name) + raise e # propagate the exception + else: + logger.debug("Thread '%s' joined successfully", self.thread_name) def _update_status(self, status_string : str): ''' @@ -104,6 +160,7 @@ def _update_status(self, status_string : str): ''' with self.status_lock: self.status = status_string + logger.debug("Thread '%s' updating status to '%s'", self.thread_name, status_string) def get_status(self) -> str: ''' Get current status of the instrument buffer thread (thread safe). @@ -143,36 +200,46 @@ def _worker(self, instrument_name : str, station : Station,\ self._update_status("Initializing") # Try to load the instrument try: + logger.info("Attemting to load instrument '%s'.", instrument_name) with station_lock: self.instrument = station.load_instrument(instrument_name) if not init_func is None: # if there is an incorrect number of arguments, init_func will raise a type error # There may be additional exceptions raised within init_func if they are not handled init_func(self.instrument, *init_args) - except Exception as e: + except: # log the error - print(f"Worker exception for {instrument_name} with initialization function {init_func} and args {init_args}.\n {e}") + logger.exception(f"Initialization exception for {instrument_name} with initialization function {init_func} and args {init_args}.") self.shutdown_signal.set() # Signal to the watchdog that the thread shut down instead of hung self._update_status("Failed to initialize") return # kill the thread. + logger.info("Thread started for instrument '%s': %r", instrument_name, self.instrument) + logger.instrument_snapshot(self.instrument) # Start the readout loop self._update_status("Running") + + loop_times = deque(maxlen = 500) # A deque for tracking the average loop time tprev = self.timefunc() while not self.shutdown_signal.is_set() and not self.global_shutdown.is_set(): - self._process_queue() self._read_parameters() - tnow : float - with self.heartbeat_lock: - tnow = self.timefunc() - self.heartbeat = tnow + + tnow = self.timefunc() delta = tnow - tprev sleep_time = self.measure_time - delta if sleep_time > 0.001: time.sleep(self.measure_time) - tprev = self.timefunc() + with self.heartbeat_lock: + tprev = self.timefunc() + loop_times.append(tprev - self.heartbeat) + self.heartbeat = tprev + + loop_times = list(loop_times) + average_dt = np.average(loop_times) * 1e3 + times_stdev = np.std(loop_times) * 1e3 + logger.debug("'%s' average loop time: %f +- %f ms (target %f ms)", self.thread_name, average_dt, times_stdev, self.measure_time * 1e3) # Clear the monitored parameters with self.parameters_lock: self.parameters_private = [] @@ -188,83 +255,105 @@ def _read_parameters(self): param : Parameter try: param = getattr(self.instrument, param_name) - except Exception as e: - print(f"Exception while reading parameter: {e}") + except: + logger.exception("Exception occured while reading parameter '%s.%s.'", self.instrument.name, param_name) else: value = param() timestamp = self.timefunc() self.buffer[param_name].append((value, timestamp)) - + + def _update_public_parameters(self): + with self.parameters_lock: + self.parameters_public = self.parameters_private.copy() + def _process_queue(self): - if not self.param_queue.empty(): - op, name = self.param_queue.get() - name_in_params = name in self.parameters_private - if op == "add" and not name_in_params: + count = 2 # to prevent infinite loops with the when parameter of a job + while self.job_queue.qsize() > 0 and count > 0: + curr_time = time.monotonic() + job = self.job_queue.get() + logger.debug("Instrument '%s', processing job %r", self.instrument.name, job) + + if job.when > curr_time and (job.when > 0): + self.job_queue.put(job) + self.job_queue.task_done() + count -= 1 + continue + + if isinstance(job, instrument_callback_job): + try: + retval = job.callback(self.instrument) + except Exception as e: + job.future.set_exception(e) + else: + job.future.set_result(retval) + + elif isinstance(job, get_parameter_job): + params = job.parameters + retval = {} + for param in params: + try: + value = getattr(self.instrument, param)() + except Exception as e: + job.future.set_exception(e) + else: + retval[param] = value + job.future.set_result(retval) + + elif isinstance(job, set_parameter_job): + for param, setval in job.set_vals.items(): + try: + getattr(self.instrument, param)(setval) + except Exception as e: + job.future.set_exception(e) + job.future.set_result(None) + + elif isinstance(job, change_monitor_status_job): + self._handle_monitor_status_job(job) + + return + + def _handle_monitor_status_job(self, job : change_monitor_status_job) -> bool: + if job.add_or_remove: # adding a monitored param + for param in job.parameters: + already_monitored = param in self.parameters_private + if already_monitored: + continue try: - param = getattr(self.instrument, name) - if not param.gettable: - raise Exception(f"Parameter {self.instrument.name}.{name} is not gettable!") - except KeyError as e: - print(f"Key error for parameter {self.instrument.name}.{name}! {e}") + # Test to make sure the requested parameter exists + qparam = getattr(self.instrument, param) except Exception as e: - # log the exceptions (keyError from getattr, or the manual exception) - print(e) + job.future.set_exception(e) + self._update_public_parameters() + return False else: - self.parameters_private.append(name) # Add the name to the list of monitored params - # add a new buffer entry if there is not already one. + # Add a new deque to the buffer dictionary if required + self.parameters_private.append(param) + logger.info("Now monitoring parameter %s.%s", self.instrument.name, param) with self.buffer_lock: - if not name in self.buffer: - self.buffer[name] = deque(maxlen =self.BUFFER_SIZE) - - elif op == 'add' and name_in_params: - # log - print(f"Parameter {self.instrument.name}.{name} is already being monitored.") - elif op == 'remove' and name_in_params: - self.parameters_private.remove(name) - print(f"Parameter {self.instrument.name}.{name} is no longer being monitored.") - elif op == 'callback': - # in this case name is actually a lambda function. + if not param in self.buffer: + self.buffer[param] = deque(maxlen = self.BUFFER_SIZE) + + else: # Remove a monitored param + for param in job.parameters: + already_monitored = param in self.parameters_private + if not already_monitored: + continue try: - name(self.instrument) + self.parameters_private.remove(param) except Exception as e: - print(f"Exception with callback function {name}: {e}") - - # Update the copy - with self.parameters_lock: - self.parameters_public = self.parameters_private.copy() + job.future.set_exception(e) + self._update_public_parameters() + return False + else: + logger.info("No longer monitoring parameter %s.%s", self.instrument.name, param) + + self._update_public_parameters() + job.future.set_result(None) + return True - - def queue_parameters(self, param_names : List[str], operation : Literal["add", "remove"] = "add") -> int: - ''' - Communicates to the thread that you would like to try to add or remove a parameter from the list - of measured parameters. - - Parameters - --------- - param_names : str | List[str] - A name or list of parameter names belonging to this instrument to add. - - operation: "add" or "remove" - Specifies whether to add or remove the requested parameter from the list of measured parameters - - Returns - ------- - Returns the number of parameters that are attempting to be added. - ''' - n = 0 - with self.parameters_lock: - for param_name in param_names: - if not param_name in self.parameters_public: - self.param_queue.put((operation, param_name)) - n += 1 - return n - - def queue_instrument_callback(self, callback : InstrumentCallback, *args): - self.param_queue.put(("callback", lambda inst: callback(inst, *args))) - -class buffered_readout: +class instrument_handler: def __init__(self, station : Station, station_lock : threading.Lock): ''' A class to handle the asynchronous buffered readout of the SET current for @@ -285,13 +374,13 @@ def __init__(self, station : Station, station_lock : threading.Lock): A function with no arguments that returns some sort of time class. For now this is set to time.monotonic to guarantee a monotonic increase in time. ''' - global __BufferExists__ + global _BufferExists - assert not __BufferExists__, "Error: Readout buffer already exists!!" + assert not _BufferExists, "Error: Readout buffer already exists!!" - __BufferExists__ = True + _BufferExists = True - self.instrument_threads : Dict[str, buffer_thread] = {} + self.instrument_threads : Dict[str, instrument_thread] = {} self.heartbeats : Dict[str, float] = {} @@ -331,7 +420,7 @@ def read_buffer(self, var_name : str | List[str], t_avg : float = 0.0, t_stop : If there are no data points in the perscribed averaging range ''' if t_stop <= 0.0: - t_stop = self.time_func() + t_stop = time.monotonic() t_start = t_stop - t_avg @@ -360,7 +449,7 @@ def read_buffer(self, var_name : str | List[str], t_avg : float = 0.0, t_stop : return retval - def get_buffer(self, param_names : str | List[str] | None = None, blocking : bool= False, timeout : float = 0.1) -> Dict[str, List[Tuple[float, float]]] | None: + def get_buffer(self, param_names : str | List[str] | Literal['all'] = 'all', blocking : bool= False, timeout : float = 0.1) -> Dict[str, List[Tuple[float, float]]]: ''' Try to copy a buffer for a parameter with optional blocking @@ -384,7 +473,7 @@ def get_buffer(self, param_names : str | List[str] | None = None, blocking : boo This will get thrown if param_names are not able to be parsed ''' buffer_copies = {} - if param_names is None: + if param_names == 'all': param_names = self.monitored_parameters param_names = make_list(param_names) for param_name in param_names: @@ -407,7 +496,7 @@ def get_buffer(self, param_names : str | List[str] | None = None, blocking : boo finally: thread.buffer_lock.release() - return buffer_copies if len(buffer_copies) > 0 else None + return buffer_copies def shutdown_instruments(self): self.global_shutdown.set() @@ -415,7 +504,7 @@ def shutdown_instruments(self): inst_thread.stop() - def add_instrument(self, name : str, param_names : str | List[str] | None = None,\ + def add_instrument(self, name : str,\ init_func : Optional[InstrumentCallback] = None,\ *init_args : Any) -> None: ''' @@ -441,48 +530,152 @@ def add_instrument(self, name : str, param_names : str | List[str] | None = None init_args : Tuple = () The arguments to get passed to the init function. By default, it is an empty tuple. ''' - param_names = make_list(param_names) # Make it iterable - # Check to see if the instrument already exists instr_thread = self.instrument_threads.get(name) if instr_thread is None: self.heartbeats[name] = time.monotonic() # Create the first heartbeat - self.instrument_threads[name] = buffer_thread(f"{name}Thread", name,\ + self.instrument_threads[name] = instrument_thread(f"{name}Thread", name,\ self.station,\ self.station_lock,\ self.global_shutdown,\ init_func, init_args) - if param_names is not None: - self.instrument_threads[name].queue_parameters(param_names) self.instrument_threads[name].start() + + def add_callback(self, instrument : str, + callback : InstrumentCallback, + *args, + wait : bool = True, + timeout : float = 60, + when : float = -1) -> Any: + """Add a callback function to an instruments job queue. + + Args: + instrument (str): _description_ + callback (InstrumentCallback): _description_ + wait (bool, optional): _description_. Defaults to True. + timeout (float, optional): _description_. Defaults to 60. + when (float, optional): When (in absolute time with time.monotonic) to do the callback. + By default, it will execute as soon as it gets to the front of the queue. + + Returns: + Any: If the instrument does not exist, it will return None. If wait is true, + it will return the result of the callback. If wait is false, it will return a future. + """ + inst = self.instrument_threads.get(instrument) + if inst is not None: + future = TunerFuture() + job = instrument_callback_job(future, callback, *args, when = when) + inst.job_queue.put(job) + + if wait: + return future.result(timeout) + else: + return future + else: + return None + + def get_parameter(self, instrument : str, + params : str | List[str], + wait : bool= True, + timeout : float = 60, + when = -1) -> Any: + """Get parameters from an instrument + + Args: + instrument (str): The name of the instrument to read from + params (str | List[str]): The name of the parameter(s) to read + wait (bool, optional): Whether or not to wait for the get command to complete. Defaults to True + timeout (float | None, optional): Timeout for waiting, defaults to 60s + + Returns: + Any: If wait is True, then it will return a dictionary of the gotten parameters. + If wait is False, it will return the Future for the job. If the instrument name is + invalid, it will return None. + """ + params = make_list(params) + + inst = self.instrument_threads.get(instrument) + if inst is not None: + future = TunerFuture() + job = get_parameter_job(future, params, when) + inst.job_queue.put(job) + + if wait: + return future.result(timeout) + else: + return future + return None - def remove_instrument(self, name : str): + def set_parameter(self, instrument : str, + set_vals : Dict[str, Any], + wait : bool = True, + timeout : float = 60, + when : float = -1) -> bool | TunerFuture: + """Set one or more parameters of an instrument. + + Args: + instrument (str): _description_ + set_vals (Dict[str, Any]): A dictionary of the parameter names and the value you want to set it to. + For example {'dac1': 1.0, 'dac2': 2.0}. + wait (bool, optional): Wait for operation to complete if True. Defaults to True. + timeout (float, optional): Timeout for waiting. Defaults to 60s. + + Returns: + bool | Future: Returns false on error and true on success. If wait is False, it will return the + future. + """ + inst = self.instrument_threads.get(instrument) + if inst is not None: + + future = TunerFuture() + job = set_parameter_job(future = future, set_vals = set_vals, when=when) + inst.job_queue.put(job) + if wait: + return future.result(timeout) + else: + return future + return False + + def remove_instrument(self, name : str, finish_jobs : bool = True): ''' Stop a specific instrument and close it. This will not delete any of the data that is still buffered. ''' if name in self.instrument_threads: thread = self.instrument_threads[name] + if finish_jobs: + thread.job_queue.join() thread.stop() - def add_parameter(self, inst_name : str, params : str | List[str]): + def monitor_parameter(self, inst_name : str, + params : str | List[str], + remove = False, + wait : bool = True, + timeout : float = 60, + when : float = -1) -> bool | TunerFuture: inst = self.instrument_threads.get(inst_name) if not inst is None: params = make_list(params) - inst.queue_parameters(params) + future = TunerFuture() + job =change_monitor_status_job(future, params, not remove, when) + + inst.job_queue.put(job) + + if wait: + return future.result(timeout) + else: + return future else: - # log - print(f"Cannot add parameter to instrument {inst_name}: it does not exist!") + logger.warning("Cannot add parameters to instrument '%s': It does not exist!", inst_name) + return False - def remove_parameter(self, inst_name : str, params : str | List[str]): - inst = self.instrument_threads.get(inst_name) - if not inst is None: - params = make_list(params) - inst.queue_parameters(params, 'remove') - else: - # log - print(f"Cannot remove parameter from instrument {inst_name}: it does not exist!") + def stop_monitoring_parameter(self, inst_name : str, + params : str | List[str], + wait : bool = True, + timeout : float = 60, + when : float = -1) -> bool | TunerFuture: + return self.monitor_parameter(inst_name, params, True, wait, timeout, when) def get_instrument_status(self, instrument_name : str) -> str: ''' @@ -500,7 +693,7 @@ def get_instrument_status(self, instrument_name : str) -> str: Returns a inst_status enumeration (which is just a string) to describe the current status of the instrument. ''' - inst_thread : buffer_thread = self.instrument_threads.get(instrument_name) + inst_thread : instrument_thread | None = self.instrument_threads.get(instrument_name) if not inst_thread is None: return inst_thread.get_status() else: @@ -536,6 +729,5 @@ def watchdog(self) -> bool: self.monitored_parameters.append(f"{name}.{param}") elif not alive and (thread_status == 'Running' or thread_status == 'Initializing'): - print(f"Instrument thread {name} is dead with status {thread_status}") - #return False - return True + logger.warning(f"Instrument thread {name} is dead with status {thread_status}") + return True \ No newline at end of file diff --git a/src/main.py b/src/main.py index 1e8ffe6..29c5e7b 100644 --- a/src/main.py +++ b/src/main.py @@ -8,24 +8,27 @@ from nicegui import app, ui from gui import tuner_gui - +from tunerlog import TunerLog gui : tuner_gui +logger : TunerLog @app.on_startup def start_tuner_gui(): - print("Starting the GUI...") - global gui + global gui, logger + logger = TunerLog("main") + logger.info("Starting the GUI...") gui = tuner_gui() @app.on_shutdown def stop_tuner_gui(): - print("Stopping the gui") + logger.warning("Stopping the GUI...") global gui gui.on_shutdown() @ui.page('/') def tuner_gui_root_page(): global gui + logger.debug("Defining the server root page") gui.root_page() ui.run(port = 8081) diff --git a/src/test.py b/src/test.py deleted file mode 100644 index ceda595..0000000 --- a/src/test.py +++ /dev/null @@ -1,10 +0,0 @@ -import qcodes -from qcodes import instrument_drivers -from qcodes.dataset import do0d, load_or_create_experiment -from qcodes.instrument import Instrument -from qcodes.instrument_drivers.stanford_research import SR830 -from qcodes.validators import Numbers -from qcodes import Parameter - -sr = SR830("lockin", "GPIB0::8::INSTR") - diff --git a/src/tunerlog.py b/src/tunerlog.py new file mode 100644 index 0000000..c4003b2 --- /dev/null +++ b/src/tunerlog.py @@ -0,0 +1,148 @@ +import logging +import colorlog +from typing import Literal, List +import sys +import datetime +from qcodes.instrument import Instrument +from nicegui import ui + +logfile = None +consoleHandler = None +fileHandler = None +uiHandler = None +history = None +loggers : dict[str, logging.Logger] = {} + +formatstr = '[%(levelname)-5s %(asctime)s] %(name)s: %(message)s' +formatstr_colored = '%(log_color)s[%(levelname)-5s %(asctime)s] %(name)s:%(reset)s %(message)s' +datefmt = '%m-%d-%Y %H:%M:%S' + +class StorageHandler(logging.Handler): + def __init__(self, level : int): + self.history : List[logging.LogRecord] = [] + super().__init__(level) + def emit(self, record : logging.LogRecord) -> None: + self.history.append(record) + +class LogElementHandler(logging.Handler): + """A logging handler that emits messages to a log element.""" + + def __init__(self, element: ui.log, level: int = logging.NOTSET) -> None: + self.element = element + super().__init__(level) + + global formatstr, datefmt + formatter = logging.Formatter(formatstr, datefmt=datefmt) + self.setFormatter(formatter) + + def emit(self, record: logging.LogRecord) -> None: + try: + level = record.levelno + colors = {logging.DEBUG: "text-blue text-sm italic", logging.INFO: "text-sm", logging.WARNING: "text-yellow-600 text-sm", logging.ERROR: "text-red", logging.CRITICAL: "text-red font-bold underline"} + msg = self.format(record) + self.element.push(msg, classes=colors[level]) + except Exception: + self.handleError(record) + +class TunerLog(logging.Logger): + def __init__(self, name : str, level : Literal['debug', 'info', 'warning', 'error'] = 'debug'): + global logfile, consoleHandler, fileHandler, loggers, formatstr, formatstr_colored, datefmt, history + try: + level_num = getattr(logging, level.upper()) + except: + ... + else: + super().__init__(name, level_num) + + if name in loggers: + return + + if consoleHandler is None: + consoleHandler = colorlog.StreamHandler(sys.stdout) + consoleHandler.setLevel(level_num) + colorFormatter = colorlog.ColoredFormatter(formatstr_colored, datefmt=datefmt,log_colors={ + 'DEBUG': 'blue', + 'INFO': 'green', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'bold_red', + }) + consoleHandler.setFormatter(colorFormatter) + + if logfile is None: + #logfile = f"../logs/QDot_tuner_{datetime.datetime.now().strftime("%m-%d-%Y_%H-%M-%S")}.log" + logfile = f"../logs/QDot_tuner_{datetime.datetime.now().strftime("%m-%d-%Y")}.log" + + if fileHandler is None: + fileHandler = logging.FileHandler(logfile) + fileHandler.setLevel(level_num) + formatter = logging.Formatter(formatstr, datefmt=datefmt) + fileHandler.setFormatter(formatter) + if history is None: + history = StorageHandler(level_num) + + self.addHandler(fileHandler) + self.addHandler(consoleHandler) + self.addHandler(history) + + self.propagate = False + self.info("Initalizing logger %s with log file '%s'", name, logfile) + + loggers[name] = self # add to list of loggers + + def add_ui_handler(self, element: ui.log, level: Literal['debug', 'info', 'warning', 'error'] = 'info'): + """Add the UI handler to all loggers, and make sure all the previous history gets sent to the ui logger. + + Args: + element (ui.log): _description_ + level (Literal['debug', 'info', 'warning', 'error'], optional): _description_. Defaults to 'info'. + """ + level_num = getattr(logging, level.upper()) + + global loggers, uiHandler, history + + if uiHandler is None: + uiHandler = LogElementHandler(element, level_num) + + for logger in loggers.values(): + if not uiHandler in logger.handlers: + logger.addHandler(uiHandler) + if history is not None: + logger.removeHandler(history) + + if history is not None: + for record in history.history: + uiHandler.emit(record) + + def instrument_snapshot(self, instrument : Instrument, level : Literal['debug', 'info', 'warning', 'error'] = 'info'): + params = [] + maxlenl = len("Parameter") + 3 + maxlenr = len("Value") + 3 + for key, item in instrument.parameters.items(): + try: + value = getattr(item ,"get")() + unit = getattr(item, "unit") + except: + value = None + unit = "" + else: + if isinstance(value, (int,float)): + valuestr = f" {value:.3g} {unit}" + else: + valuestr = " " + str(value) + if len(valuestr) > 30: + valuestr = valuestr[:30]+'...' + params.append((key, valuestr)) + if len(valuestr) > maxlenr: + maxlenr = len(valuestr) + if len(key) > maxlenl: + maxlenl = len(key) + total_len = maxlenl + maxlenr + 3 + output = f"Instrument '{instrument}':\n{' Parameter':<{maxlenl}} | {' Value':<{maxlenr}}\n{'-'*total_len}\n" + for key, valuestr in params: + output += f"{key:<{maxlenl}} : {valuestr:<{maxlenr}}\n" + + try: + getattr(self, level)(output) + except: + self.exception("Error printing instrument snapshot") \ No newline at end of file From c83057ee8d362cf3b6875f1c773caad0c421eb1c Mon Sep 17 00:00:00 2001 From: Ben Van Osch Date: Wed, 15 Apr 2026 17:02:59 -0400 Subject: [PATCH 14/36] Dil Fridge Computer Compatibility Edits Added a test station config file for testing the dil fridge. Also added a recursive getattr function to the instrument handler to allow the use of the spi rack. Also, removed the ui.update to increase stability for older lab pcs. Also, ensured any pre-existing ui logger handler is removed on startup Finally, changed the timestamp for the logger to be compatible with Windows. --- configs/test_station.yaml | 16 ++++++++++++++++ src/gui.py | 29 +++++++++++++++++++++++------ src/instrument_handler.py | 17 ++++++++++++----- src/tunerlog.py | 9 ++++++--- 4 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 configs/test_station.yaml diff --git a/configs/test_station.yaml b/configs/test_station.yaml new file mode 100644 index 0000000..3c17e13 --- /dev/null +++ b/configs/test_station.yaml @@ -0,0 +1,16 @@ +instruments: + DummyInst: + type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument + DummyInst2: + type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument + agilent_left: + type: qcodes.instrument_drivers.agilent.Agilent_34410A.Agilent34410A + address: TCPIP0::169.254.10.10::inst0::INSTR + agilent_right: + type: qcodes.instrument_drivers.agilent.Agilent_34410A.Agilent34410A + address: TCPIP0::169.254.4.10::inst0::INSTR + spi_rack: + type: qblox_instruments.qcodes_drivers.spi_rack.SpiRack #qcodes.instrument_drivers.SpiRack.spi_rack.SpiRack + address: COM3 + + \ No newline at end of file diff --git a/src/gui.py b/src/gui.py index 32f9c29..af30973 100644 --- a/src/gui.py +++ b/src/gui.py @@ -20,6 +20,7 @@ from experiment_thread import ExperimentThread from qcodes.station import Station from qcodes.instrument_drivers.mock_instruments import DummyInstrument +from qcodes.instrument import Instrument from qcodes.parameters import Parameter import random import os, sys @@ -60,19 +61,35 @@ def __init__(self): ''' Creates an instance of the tuner gui ''' - + self.logger = TunerLog("TunerGUI") self.start_time = time.monotonic() - self.station = Station(config_file = "../configs/dummy_station.yaml") + self.station = Station(config_file = "../configs/test_station.yaml") self.station_lock = threading.Lock() self.readout = create_buffer_instance(self.station, self.station_lock) #self.readout.run() + def init_agilent(instrument: Instrument, *args): + instrument.NPLC(1.0) + instrument.range_auto('off') + + def init_spi_rack(instrument: Instrument, *args): + + instrument.add_spi_module(8, 'D5a', 'module1') + args[0].instrument_snapshot(instrument.module1.dac0) + return + self.readout.add_instrument("DummyInst") self.readout.add_instrument("DummyInst2") + self.readout.add_instrument("agilent_left", init_agilent) + self.readout.add_instrument("agilent_right", init_agilent) + self.readout.add_instrument("spi_rack", init_spi_rack, self.logger) - self.readout.monitor_parameter("DummyInst", ["rand1", "rand2"]) + self.readout.monitor_parameter('agilent_left', ['volt']) + self.readout.monitor_parameter('agilent_right', ['volt']) + self.readout.set_parameter('agilent_left', {'range': 1}) + self.readout.set_parameter('spi_rack', {'module1.dac0.voltage': 1}) self.experiment_thread = ExperimentThread() self.experiment_thread.run() @@ -81,7 +98,7 @@ def __init__(self): self.experiment_thread.add_job(testjob, ("Hello",)) self.abort_signal = threading.Event() - self.logger = TunerLog("TunerGUI") + # The below methods define the layout of the GUI @@ -166,7 +183,7 @@ def root_page(self): self.logger.add_ui_handler(self.ui_log) self.logger.info("Added NiceGUI UI handler to logger.") - ui.timer(0.05, self.update_liveplot) + ui.timer(0.25, self.update_liveplot) ui.timer(0.5, self.watchdog_timer) def header(self): @@ -275,7 +292,7 @@ def update_liveplot(self): self.ax.legend(self.lines, keys, ) self.liveplot.update() - ui.update() + #ui.update() def experiment_progress_bar(self): diff --git a/src/instrument_handler.py b/src/instrument_handler.py index 163d806..f4b5fb2 100644 --- a/src/instrument_handler.py +++ b/src/instrument_handler.py @@ -121,7 +121,7 @@ def __init__(self, thread_name : str,\ self.thread_name = thread_name self.thread = threading.Thread(target = self._worker,\ name = self.thread_name,\ - args = (instrument_name, station, station_lock, init_func, init_args)) + args = (instrument_name, station, station_lock, init_func, *init_args)) self.instrument : Instrument # DO NOT ACCESS EXTERNALLY @@ -293,7 +293,7 @@ def _process_queue(self): retval = {} for param in params: try: - value = getattr(self.instrument, param)() + value = self.getattr_recursive(self.instrument, param)() except Exception as e: job.future.set_exception(e) else: @@ -303,7 +303,7 @@ def _process_queue(self): elif isinstance(job, set_parameter_job): for param, setval in job.set_vals.items(): try: - getattr(self.instrument, param)(setval) + self.getattr_recursive(self.instrument, param)(setval) except Exception as e: job.future.set_exception(e) job.future.set_result(None) @@ -312,7 +312,14 @@ def _process_queue(self): self._handle_monitor_status_job(job) return - + def getattr_recursive(self, obj, param : str): + splitted = param.split('.', maxsplit = 1) + attr = getattr(obj, splitted[0]) + if len(splitted) == 1: + return attr + else: + return self.getattr_recursive(attr, splitted[1]) + def _handle_monitor_status_job(self, job : change_monitor_status_job) -> bool: if job.add_or_remove: # adding a monitored param for param in job.parameters: @@ -539,7 +546,7 @@ def add_instrument(self, name : str,\ self.station,\ self.station_lock,\ self.global_shutdown,\ - init_func, init_args) + init_func, *init_args) self.instrument_threads[name].start() diff --git a/src/tunerlog.py b/src/tunerlog.py index c4003b2..bf4abbf 100644 --- a/src/tunerlog.py +++ b/src/tunerlog.py @@ -71,7 +71,7 @@ def __init__(self, name : str, level : Literal['debug', 'info', 'warning', 'erro if logfile is None: #logfile = f"../logs/QDot_tuner_{datetime.datetime.now().strftime("%m-%d-%Y_%H-%M-%S")}.log" - logfile = f"../logs/QDot_tuner_{datetime.datetime.now().strftime("%m-%d-%Y")}.log" + logfile = f"../logs/QDot_tuner_{datetime.datetime.now().strftime('%m-%d-%Y')}.log" if fileHandler is None: fileHandler = logging.FileHandler(logfile) @@ -101,8 +101,11 @@ def add_ui_handler(self, element: ui.log, level: Literal['debug', 'info', 'warni global loggers, uiHandler, history - if uiHandler is None: - uiHandler = LogElementHandler(element, level_num) + if uiHandler is not None: + for logger in loggers.values(): + logger.removeHandler(uiHandler) + + uiHandler = LogElementHandler(element, level_num) for logger in loggers.values(): if not uiHandler in logger.handlers: From b5e5832fc4e34eb01b83ea99adc0ab9132193eec Mon Sep 17 00:00:00 2001 From: VanOschB Date: Mon, 20 Apr 2026 13:21:12 -0400 Subject: [PATCH 15/36] Experiment handler and base development Started developing experiment_base.py, and fleshing out experiment_handler (renamed from experiment_thread). added structure like futures to the experiment_jobs, callbacks and added the abort_event to the thread loop. Defined a sweep layer dataclass in experiment_base. --- src/experiment_base.py | 101 ++++++++++++++++++++++++++++++ src/experiment_handler.py | 127 ++++++++++++++++++++++++++++++++++++++ src/experiment_thread.py | 77 ----------------------- src/gui.py | 2 +- src/instrument_handler.py | 2 +- 5 files changed, 230 insertions(+), 79 deletions(-) create mode 100644 src/experiment_base.py create mode 100644 src/experiment_handler.py delete mode 100644 src/experiment_thread.py diff --git a/src/experiment_base.py b/src/experiment_base.py new file mode 100644 index 0000000..e471d27 --- /dev/null +++ b/src/experiment_base.py @@ -0,0 +1,101 @@ +''' +File: experiment_base.py +Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) + +Defines SweepLayer, AbstractSweep, and do_sweep_job for use with ExperimentThread. + +All hardware I/O is routed through the instrument_handler using set_parameter and +get_parameter — the sweep never touches QCoDeS instruments directly. + +A SweepLayer describes one axis: which instrument parameter to drive and over which +setpoints. An AbstractSweep composes layers outermost → innermost, and calls a +user-supplied measurement callback at every innermost point. + +do_sweep_job matches the ExperimentThread calling convention (f(*args, abort_event)) +and is registered like: + + thread.add_job(do_sweep_job, (sweep, instr_handler), priority=1) + +Measurement callback signature +-------------------------------- + def my_measure(instr_handler: instrument_handler, + setpoints: tuple) -> Any: + return instr_handler.get_parameter("vna", "S21") + +The return value is stored in AbstractSweep.results as: + {'setpoints': (v0, v1, ...), 'data': } +''' + +# Imports + +from __future__ import annotations + +import time +import threading +from dataclasses import dataclass +from collections.abc import Callable +from typing import Any + +from instrument_handler import instrument_handler +from tunerlog import TunerLog + +logger = TunerLog("Expt. Base") + +@dataclass +class SweepLayers: + + ''' + One axis of a multi-dimensional sweep. + + Parameters resolved through instrument_handler + ----------------------------------------------- + instrument : str + The instrument name as registered in the station / instrument_handler + (e.g. "dac", "vna"). + parameter : str + The QCoDeS parameter name on that instrument (e.g. "ch1_v", "frequency"). + Supports dotted sub-parameters in the same format as instrument_handler + (e.g. "ch1.voltage"). + setpoints : Sequence + Ordered values to step through on this axis. + delay : float + Seconds to wait after set_parameter completes before continuing. + Uses interruptible sleep so aborts remain responsive. + before_sweep : Callable | None + Called once before this axis begins. + Signature: (layer: SweepLayer) -> None + after_sweep : Callable | None + Called once after this axis finishes (runs even on abort via finally). + Signature: (layer: SweepLayer) -> None + after_step : Callable | None + Called after each step on this layer, before descending into inner layers. + Signature: (layer: SweepLayer, value: Any) -> None + name : str + Human-readable label for logging. Falls back to "instrument.parameter". + ''' + + instrument: str + parameter: str + layers: list[list] + before_sweep: Callable[[SweepLayers], None] | None = None + after_sweep: Callable[[SweepLayers], None] | None = None + after_step: Callable[[SweepLayers, Any], None] | None = None + name: str = '' + + def __post_init__(self) -> None: + if not self.instrument: + raise ValueError("SweepLayer.instrument must not be empty.") + if not self.parameter: + raise ValueError("SweepLayer.parameter must not be empty.") + if len(self.setpoints) == 0: + raise ValueError("SweepLayer.setpoints must not be empty.") + + @property + def label(self) -> str: + return self.name or f"{self.instrument}.{self.parameter}" + +class Sweep: + + def __init__(self): + + pass diff --git a/src/experiment_handler.py b/src/experiment_handler.py new file mode 100644 index 0000000..bedf8ec --- /dev/null +++ b/src/experiment_handler.py @@ -0,0 +1,127 @@ +''' +File: experiment_thread.py +Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) + +This file contains classes related to running experiments from the autotuning code. Experiments are put into a queue, which +keeps track of which experiments to in which order. The queue can be stached and wait for user input to continue, or can be cleared +with an Abort call from the user. +''' + +# Imports + + +import threading +from queue import PriorityQueue +from collections.abc import Callable +from dataclasses import dataclass +from instrument_handler import TunerFuture +from enum import Enum +from typing import Tuple, Dict, Any, Literal, Protocol, Optional, Deque +from qcodes.instrument import Instrument +from tunerlog import TunerLog + +logger = TunerLog('Expt. Control') + +class Expt_status(Enum): + queued = "Queued" + running = "Running" + failed = "Failed" + invalid = "Invalid" + +class ExperimentCallback(Protocol): + def __call__(self, instrument: Instrument, *args: Any) -> Any: + ... + +@dataclass +class experiment_job: + future : TunerFuture + when : float + type : str + +class experiment_callback_job(experiment_job): + def __init__(self, future : TunerFuture, callback : ExperimentCallback, *args, when : float = -1): + self.callback : Callable[[Instrument], Any] = lambda inst: callback(inst, args) + super().__init__(future, when, "instrument_callback") + +class ExperimentThread: + + def __init__(self): + + + self.job_event = threading.Event() + self.abort_event = threading.Event() + self.shutdown_event = threading.Event() + self.job_queue = PriorityQueue() + self.THREAD_NAME = "ExperimentThread" + self.thread = threading.Thread(target = self.__thread_loop__, name = self.THREAD_NAME) + + def run(self): + + self.thread.start() + + def join(self): + print("Stopping the experiment thread...") + self.shutdown_event.set() + self.thread.join() + + def __assert_correct_thread__(self): + + assert threading.current_thread().name == self.THREAD_NAME, f"The current thread, {threading.current_thread().name}, is not the Experiment Thread." + + def add_job(self, + f: Callable, + args: tuple = (), + priority: int = 1, + wait: bool = True, + timeout: float = None): + + future = TunerFuture() + + job = (priority, (f, args, future)) + self.job_queue.put(job) + + # Wake the thread + self.job_event.set() + + if wait: + return future.result(timeout) + return future + + def abort(self): + + self.abort_event.set() + + def __thread_loop__(self): + print("Starting the Experiment Thread Worker") + + while not self.shutdown_event.is_set(): + + self.job_event.wait() + + while self.job_queue.qsize() > 0: + + if self.abort_event.is_set(): # Clear remaining jobs safely + while not self.job_queue.empty(): + try: + _, (_, _, future) = self.job_queue.get_nowait() + future.set_exception(RuntimeError("Experiment aborted")) + self.job_queue.task_done() + except: + break + self.abort_event.clear() + continue + + priority, data = self.job_queue.get() + f, args, future = data + + try: + result = f(*args, self.abort_event) + except Exception as e: + future.set_exception(e) + else: + future.set_result(result) + + self.job_queue.task_done() + + # reset event once queue is empty + self.job_event.clear() \ No newline at end of file diff --git a/src/experiment_thread.py b/src/experiment_thread.py deleted file mode 100644 index 4192b6a..0000000 --- a/src/experiment_thread.py +++ /dev/null @@ -1,77 +0,0 @@ -''' -File: experiment_thread.py -Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) - -This file contains classes related to running experiments from the autotuning code. Experiments are put into a queue, which -keeps track of which experiments to in which order. The queue can be stached and wait for user input to continue, or can be cleared -with an Abort call from the user. -''' - -# Imports - - -import threading -from queue import PriorityQueue -from collections.abc import Callable - - -class ExperimentThread: - - - def __init__(self): - - - self.job_event = threading.Event() - self.abort_event = threading.Event() - self.shutdown_event = threading.Event() - self.job_queue = PriorityQueue() - self.THREAD_NAME = "ExperimentThread" - self.thread = threading.Thread(target = self.__thread_loop__, name = self.THREAD_NAME) - - - def run(self): - - self.thread.start() - - def join(self): - print("Stopping the experiment thread...") - self.shutdown_event.set() - self.thread.join() - - def __assert_correct_thread__(self): - - assert threading.current_thread().name == self.THREAD_NAME, f"The current thread, {threading.current_thread().name}, is not the Experiment Thread." - - def add_job(self, - f: Callable, - args, - priority: int = 1): - print(args) - self.job_queue.put((priority,(f, args))) - - def abort(self): - - self.abort_event.set() - - - def __thread_loop__(self): - print("Starting the Experiment Thread Worker") - - while not self.shutdown_event.is_set(): - - self.job_event.wait(timeout = 1) - - if self.job_queue.qsize() > 0: - priority, data = self.job_queue.get() - - f, args = data - - print(f"Doing job: {data}") - - f(*args, self.abort_event) - - self.job_queue.task_done() - - while self.abort_event.is_set(): - self.job_queue.get() - self.job_queue.task_done() \ No newline at end of file diff --git a/src/gui.py b/src/gui.py index af30973..895f99f 100644 --- a/src/gui.py +++ b/src/gui.py @@ -17,7 +17,7 @@ import time from instrument_handler import create_buffer_instance import time -from experiment_thread import ExperimentThread +from experiment_handler import ExperimentThread from qcodes.station import Station from qcodes.instrument_drivers.mock_instruments import DummyInstrument from qcodes.instrument import Instrument diff --git a/src/instrument_handler.py b/src/instrument_handler.py index f4b5fb2..ab69692 100644 --- a/src/instrument_handler.py +++ b/src/instrument_handler.py @@ -1,5 +1,5 @@ ''' -File: buffered_readout.py +File: instrument_handler.py Author: Mason Daub (mjdaub@uwaterloo.ca) From eea3b957a19f7144fe4f32808869cae85571610c Mon Sep 17 00:00:00 2001 From: Ben Van Osch Date: Mon, 27 Apr 2026 13:20:51 -0400 Subject: [PATCH 16/36] Implementing Sweeps The first iteration of sweep functions added to the autotuner. --- .vscode/launch.json | 3 +- src/experiment_base.py | 132 +++++++++++++++++++++++--------------- src/gui.py | 72 ++++++++++++++++----- src/instrument_handler.py | 6 +- 4 files changed, 142 insertions(+), 71 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index a053f66..74857e9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,8 @@ "program": "${workspaceFolder}/src/main.py", "cwd": "${workspaceFolder}/src", "console": "integratedTerminal", - "justMyCode": true + "justMyCode": true, + "python": "C:/Users/BaughLaflamme/anaconda3/envs/autotunning/python.exe" } ] } \ No newline at end of file diff --git a/src/experiment_base.py b/src/experiment_base.py index e471d27..1f7cdd8 100644 --- a/src/experiment_base.py +++ b/src/experiment_base.py @@ -32,6 +32,7 @@ def my_measure(instr_handler: instrument_handler, import time import threading +import numpy as np from dataclasses import dataclass from collections.abc import Callable from typing import Any @@ -42,60 +43,87 @@ def my_measure(instr_handler: instrument_handler, logger = TunerLog("Expt. Base") @dataclass -class SweepLayers: - - ''' - One axis of a multi-dimensional sweep. - - Parameters resolved through instrument_handler - ----------------------------------------------- - instrument : str - The instrument name as registered in the station / instrument_handler - (e.g. "dac", "vna"). - parameter : str - The QCoDeS parameter name on that instrument (e.g. "ch1_v", "frequency"). - Supports dotted sub-parameters in the same format as instrument_handler - (e.g. "ch1.voltage"). - setpoints : Sequence - Ordered values to step through on this axis. - delay : float - Seconds to wait after set_parameter completes before continuing. - Uses interruptible sleep so aborts remain responsive. - before_sweep : Callable | None - Called once before this axis begins. - Signature: (layer: SweepLayer) -> None - after_sweep : Callable | None - Called once after this axis finishes (runs even on abort via finally). - Signature: (layer: SweepLayer) -> None - after_step : Callable | None - Called after each step on this layer, before descending into inner layers. - Signature: (layer: SweepLayer, value: Any) -> None - name : str - Human-readable label for logging. Falls back to "instrument.parameter". - ''' - - instrument: str - parameter: str - layers: list[list] - before_sweep: Callable[[SweepLayers], None] | None = None - after_sweep: Callable[[SweepLayers], None] | None = None - after_step: Callable[[SweepLayers, Any], None] | None = None - name: str = '' - - def __post_init__(self) -> None: - if not self.instrument: - raise ValueError("SweepLayer.instrument must not be empty.") - if not self.parameter: - raise ValueError("SweepLayer.parameter must not be empty.") - if len(self.setpoints) == 0: - raise ValueError("SweepLayer.setpoints must not be empty.") +class SweepLayer: + param: str + start: float + end: float + num_points: int + measurement_time: float + + def __post_init__(self): + if '.' not in self.param: + raise ValueError("param must be 'instrument.parameter'") + if self.num_points <= 0: + raise ValueError("num_points must be > 0") @property - def label(self) -> str: - return self.name or f"{self.instrument}.{self.parameter}" + def instrument(self): + return self.param.split('.', 1)[0] + + @property + def parameter(self): + return self.param.split('.', 1)[1] + +class AbstractSweep: + + def __init__(self, layers, measure): + self.layers = layers + self.measure = measure + self.results = [] + + def run(self, instr_handler, abort_event): + self._run_layer(0, instr_handler, abort_event, []) + + def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): + + if idx == len(self.layers): + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + + data = self.measure(instr_handler, tuple(current_setpoints)) + self.results.append({ + "setpoints": tuple(current_setpoints), + "data": data + }) + return + + layer = self.layers[idx] + + values = np.linspace(layer.start, layer.end, layer.num_points) + + for val in values: + + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + + logger.info(f"[SWEEP] Step: setting values {val}") + + # Set parameter + instr_handler.set_parameter( + layer.instrument, + {layer.parameter: float(val)}, + wait=True + ) + + # Measurement wait (interruptible) + t0 = time.monotonic() + while time.monotonic() - t0 < layer.measurement_time: + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + time.sleep(0.01) + + logger.info("[SWEEP] Measuring...") + -class Sweep: - def __init__(self): + # recurse + self._run_layer( + idx + 1, + instr_handler, + abort_event, + current_setpoints + [float(val)] + ) - pass + def do_sweep_job(sweep, instr_handler, abort_event): + sweep.run(instr_handler, abort_event) + return sweep.results diff --git a/src/gui.py b/src/gui.py index 895f99f..47c857d 100644 --- a/src/gui.py +++ b/src/gui.py @@ -25,6 +25,7 @@ import random import os, sys from tunerlog import TunerLog +from experiment_base import SweepLayer, AbstractSweep class RandomDummy(DummyInstrument): ''' @@ -67,8 +68,7 @@ def __init__(self): self.station = Station(config_file = "../configs/test_station.yaml") self.station_lock = threading.Lock() - self.readout = create_buffer_instance(self.station, self.station_lock) - #self.readout.run() + self.instrument_handler = create_buffer_instance(self.station, self.station_lock) def init_agilent(instrument: Instrument, *args): instrument.NPLC(1.0) @@ -80,16 +80,16 @@ def init_spi_rack(instrument: Instrument, *args): args[0].instrument_snapshot(instrument.module1.dac0) return - self.readout.add_instrument("DummyInst") - self.readout.add_instrument("DummyInst2") - self.readout.add_instrument("agilent_left", init_agilent) - self.readout.add_instrument("agilent_right", init_agilent) - self.readout.add_instrument("spi_rack", init_spi_rack, self.logger) + self.instrument_handler.add_instrument("DummyInst") + self.instrument_handler.add_instrument("DummyInst2") + self.instrument_handler.add_instrument("agilent_left", init_agilent) + self.instrument_handler.add_instrument("agilent_right", init_agilent) + self.instrument_handler.add_instrument("spi_rack", init_spi_rack, self.logger) - self.readout.monitor_parameter('agilent_left', ['volt']) - self.readout.monitor_parameter('agilent_right', ['volt']) - self.readout.set_parameter('agilent_left', {'range': 1}) - self.readout.set_parameter('spi_rack', {'module1.dac0.voltage': 1}) + self.instrument_handler.monitor_parameter('agilent_left', ['volt']) + self.instrument_handler.monitor_parameter('agilent_right', ['volt']) + self.instrument_handler.set_parameter('agilent_left', {'range': 1}) + self.instrument_handler.set_parameter('spi_rack', {'module1.dac0.voltage': 1}) self.experiment_thread = ExperimentThread() self.experiment_thread.run() @@ -125,7 +125,7 @@ def root_page(self): ui.item('Instrument Information', on_click=lambda : ui.notify("Loading Instrument Information...")) ui.item('Device Information', on_click=lambda : ui.notify("Loading Device Information...")) - stages = ['Setup','Bootstrapping','Coarse Tuning','Virtual Gating','Charge State Tuning','Fine Tuning'] + stages = ['Debug', 'Setup','Bootstrapping','Coarse Tuning','Virtual Gating','Charge State Tuning','Fine Tuning'] with ui.tabs() as tabs: @@ -169,6 +169,17 @@ def root_page(self): ui.label('Collecting Fine Tuning Information...') + with ui.tab_panel('Debug'): + + ui.label('Debug / Manual Controls') + + ui.button( + 'Run Test Sweep', + on_click=self.run_test_sweep + ) + + self.debug_status = ui.label('Idle') + with splitter1.after: with ui.splitter(horizontal = True) as splitter2: @@ -186,6 +197,37 @@ def root_page(self): ui.timer(0.25, self.update_liveplot) ui.timer(0.5, self.watchdog_timer) + def run_test_sweep(self): + + self.debug_status.set_text("Running sweep...") + + self.logger.info("Sweep job started") + + sweep = AbstractSweep( + layers=[ + SweepLayer('spi_rack.module1.dac0.voltage', 0.0, 0.1, 500, 0.01), + ], + measure=lambda ih, sp: ih.read_buffer('agilent_right.volt') + ) + + future = self.experiment_thread.add_job( + AbstractSweep.do_sweep_job, + args=(sweep, self.instrument_handler), + wait=False + ) + + def check_result(): + try: + result = future.result(timeout=0) + except TimeoutError: + ui.timer(0.1, check_result, once=True) + except Exception as e: + self.debug_status.set_text(f"Error: {e}") + else: + self.debug_status.set_text(f"Sweep complete: {len(result)} points") + + check_result() + def header(self): """ @@ -261,7 +303,7 @@ def update_liveplot(self): colors = ['tab:blue', 'tab:red', 'tab:orange', 'tab:purple', 'tab:green'] linestyles = ['-', '--', '-.', ':'] - retval = self.readout.get_buffer() + retval = self.instrument_handler.get_buffer() if retval is None: return else: @@ -359,7 +401,7 @@ def on_autotune(self): pass def watchdog_timer(self): - if not self.readout.watchdog(): + if not self.instrument_handler.watchdog(): # Trigger a reset self.logger.error("Readout buffer watchdog detected a problem. Triggering a reset.") #python = sys.executable # path to the Python interpreter @@ -372,5 +414,5 @@ def on_abort(self): def on_shutdown(self): self.abort_signal.set() # Abort any currently running experiments. - self.readout.shutdown_instruments() + self.instrument_handler.shutdown_instruments() self.experiment_thread.join() \ No newline at end of file diff --git a/src/instrument_handler.py b/src/instrument_handler.py index ab69692..85790ac 100644 --- a/src/instrument_handler.py +++ b/src/instrument_handler.py @@ -80,7 +80,7 @@ class instrument_job: class instrument_callback_job(instrument_job): def __init__(self, future : TunerFuture, callback : InstrumentCallback, *args, when : float = -1): - self.callback : Callable[[Instrument], Any] = lambda inst: callback(inst, args) + self.callback : Callable[[Instrument], Any] = lambda inst: callback(inst, *args) super().__init__(future, when, "instrument_callback") class get_parameter_job(instrument_job): @@ -254,7 +254,7 @@ def _read_parameters(self): for param_name in self.parameters_private: param : Parameter try: - param = getattr(self.instrument, param_name) + param = self.getattr_recursive(self.instrument, param_name) except: logger.exception("Exception occured while reading parameter '%s.%s.'", self.instrument.name, param_name) else: @@ -328,7 +328,7 @@ def _handle_monitor_status_job(self, job : change_monitor_status_job) -> bool: continue try: # Test to make sure the requested parameter exists - qparam = getattr(self.instrument, param) + qparam = self.getattr_recursive(self.instrument, param) except Exception as e: job.future.set_exception(e) self._update_public_parameters() From e44c41f14841ae4b08e9dc242ca628b582932802 Mon Sep 17 00:00:00 2001 From: Ben Van Osch Date: Tue, 28 Apr 2026 10:43:24 -0400 Subject: [PATCH 17/36] Second Set of Sweep Changes First, I changed the experiment_handler to be more like an analogue to instrument_handler. Then, I added the do_seep_job and do_sweep class and method to the experiment_handler. Then all the specific methods to run the sweep itself are defined within experiment_base. Then, I updated the debug button accordingly. Finally, I updated the main.py to reflect these changes in name and function for experiment_handler. --- configs/test_station.yaml | 4 --- src/experiment_base.py | 66 ++++++++++++++++++++++++++++++--------- src/experiment_handler.py | 54 +++++++++++++++++++++++++++++++- src/gui.py | 34 +++++++++----------- src/main.py | 34 ++++++++++++++------ 5 files changed, 145 insertions(+), 47 deletions(-) diff --git a/configs/test_station.yaml b/configs/test_station.yaml index 3c17e13..b7da6f6 100644 --- a/configs/test_station.yaml +++ b/configs/test_station.yaml @@ -1,8 +1,4 @@ instruments: - DummyInst: - type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument - DummyInst2: - type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument agilent_left: type: qcodes.instrument_drivers.agilent.Agilent_34410A.Agilent34410A address: TCPIP0::169.254.10.10::inst0::INSTR diff --git a/src/experiment_base.py b/src/experiment_base.py index 1f7cdd8..3880bda 100644 --- a/src/experiment_base.py +++ b/src/experiment_base.py @@ -2,13 +2,13 @@ File: experiment_base.py Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) -Defines SweepLayer, AbstractSweep, and do_sweep_job for use with ExperimentThread. +Defines SweepLayer, Sweep, and do_sweep_job for use with ExperimentThread. All hardware I/O is routed through the instrument_handler using set_parameter and get_parameter — the sweep never touches QCoDeS instruments directly. A SweepLayer describes one axis: which instrument parameter to drive and over which -setpoints. An AbstractSweep composes layers outermost → innermost, and calls a +setpoints. An Sweep composes layers outermost → innermost, and calls a user-supplied measurement callback at every innermost point. do_sweep_job matches the ExperimentThread calling convention (f(*args, abort_event)) @@ -30,17 +30,22 @@ def my_measure(instr_handler: instrument_handler, from __future__ import annotations +import csv +import os +from datetime import datetime + import time import threading import numpy as np from dataclasses import dataclass from collections.abc import Callable from typing import Any - -from instrument_handler import instrument_handler +from qcodes.station import Station +from instrument_handler import TunerFuture +from experiment_handler import ExperimentThread, experiment_job from tunerlog import TunerLog -logger = TunerLog("Expt. Base") +logger = TunerLog('Exp. Base') @dataclass class SweepLayer: @@ -64,15 +69,47 @@ def instrument(self): def parameter(self): return self.param.split('.', 1)[1] -class AbstractSweep: +class Sweep: def __init__(self, layers, measure): self.layers = layers self.measure = measure self.results = [] + self.filename = f"sweep_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + desktop = os.path.join(os.path.expanduser("~"), "Desktop") + self.csv_path = os.path.join(desktop, self.filename) + + self._csv_file = None + self._csv_writer = None + + def _open_csv(self): + self._csv_file = open(self.csv_path, "w", newline="") + self._csv_writer = csv.writer(self._csv_file) + + header = [f"setpoint_{i}" for i in range(len(self.layers))] + ["data"] + self._csv_writer.writerow(header) + + + def _close_csv(self): + if self._csv_file is not None: + self._csv_file.close() + def run(self, instr_handler, abort_event): - self._run_layer(0, instr_handler, abort_event, []) + + try: + self._open_csv() + + self._run_layer( + 0, + instr_handler, + abort_event, + current_setpoints=[] + ) + + finally: + self._close_csv() def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): @@ -81,10 +118,17 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): raise RuntimeError("Sweep aborted") data = self.measure(instr_handler, tuple(current_setpoints)) + self.results.append({ "setpoints": tuple(current_setpoints), "data": data }) + + # Write to CSV + row = list(current_setpoints) + [data] + self._csv_writer.writerow(row) + self._csv_file.flush() + return layer = self.layers[idx] @@ -114,16 +158,10 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): logger.info("[SWEEP] Measuring...") - - # recurse self._run_layer( idx + 1, instr_handler, abort_event, current_setpoints + [float(val)] - ) - - def do_sweep_job(sweep, instr_handler, abort_event): - sweep.run(instr_handler, abort_event) - return sweep.results + ) \ No newline at end of file diff --git a/src/experiment_handler.py b/src/experiment_handler.py index bedf8ec..fe6c216 100644 --- a/src/experiment_handler.py +++ b/src/experiment_handler.py @@ -20,8 +20,29 @@ from qcodes.instrument import Instrument from tunerlog import TunerLog +_ExperimentThreadInstance = None +_ExperimentHandlerInstance = None + logger = TunerLog('Expt. Control') +def create_experiment_thread(): + global _ExperimentThreadInstance + + if _ExperimentThreadInstance is None: + _ExperimentThreadInstance = ExperimentThread() + _ExperimentThreadInstance.run() + + return _ExperimentThreadInstance + +def get_experiment_handler(): + global _ExperimentHandlerInstance + + if _ExperimentHandlerInstance is None: + thread = create_experiment_thread() + _ExperimentHandlerInstance = experiment_handler(thread) + + return _ExperimentHandlerInstance + class Expt_status(Enum): queued = "Queued" running = "Running" @@ -124,4 +145,35 @@ def __thread_loop__(self): self.job_queue.task_done() # reset event once queue is empty - self.job_event.clear() \ No newline at end of file + self.job_event.clear() + + +class experiment_handler: + + def __init__(self, experiment_thread): + self.experiment_thread = experiment_thread + + def do_sweep(self, + sweep, + instrument_handler, + wait: bool = True, + timeout: float = 60, + filename: str = "sweep.csv"): + + future = TunerFuture() + + def sweep_fn(abort_event): + result = sweep.run(instrument_handler, abort_event) + future.set_result(result) + return result + + self.experiment_thread.add_job( + sweep_fn, + args=(), + wait=False + ) + + if wait: + return future.result(timeout) + else: + return future \ No newline at end of file diff --git a/src/gui.py b/src/gui.py index 47c857d..517a5b8 100644 --- a/src/gui.py +++ b/src/gui.py @@ -17,7 +17,7 @@ import time from instrument_handler import create_buffer_instance import time -from experiment_handler import ExperimentThread +from experiment_handler import get_experiment_handler from qcodes.station import Station from qcodes.instrument_drivers.mock_instruments import DummyInstrument from qcodes.instrument import Instrument @@ -25,7 +25,7 @@ import random import os, sys from tunerlog import TunerLog -from experiment_base import SweepLayer, AbstractSweep +from experiment_base import SweepLayer, Sweep class RandomDummy(DummyInstrument): ''' @@ -70,6 +70,8 @@ def __init__(self): self.instrument_handler = create_buffer_instance(self.station, self.station_lock) + self.experiment_handler = get_experiment_handler() + def init_agilent(instrument: Instrument, *args): instrument.NPLC(1.0) instrument.range_auto('off') @@ -77,25 +79,16 @@ def init_agilent(instrument: Instrument, *args): def init_spi_rack(instrument: Instrument, *args): instrument.add_spi_module(8, 'D5a', 'module1') + instrument.add_spi_module(7, 'D5a', 'module2') args[0].instrument_snapshot(instrument.module1.dac0) return - self.instrument_handler.add_instrument("DummyInst") - self.instrument_handler.add_instrument("DummyInst2") self.instrument_handler.add_instrument("agilent_left", init_agilent) self.instrument_handler.add_instrument("agilent_right", init_agilent) self.instrument_handler.add_instrument("spi_rack", init_spi_rack, self.logger) self.instrument_handler.monitor_parameter('agilent_left', ['volt']) self.instrument_handler.monitor_parameter('agilent_right', ['volt']) - self.instrument_handler.set_parameter('agilent_left', {'range': 1}) - self.instrument_handler.set_parameter('spi_rack', {'module1.dac0.voltage': 1}) - - self.experiment_thread = ExperimentThread() - self.experiment_thread.run() - - testjob = lambda a, event: print(f"{a} from {threading.current_thread().name}!") - self.experiment_thread.add_job(testjob, ("Hello",)) self.abort_signal = threading.Event() @@ -200,29 +193,32 @@ def root_page(self): def run_test_sweep(self): self.debug_status.set_text("Running sweep...") - self.logger.info("Sweep job started") - sweep = AbstractSweep( + sweep = Sweep( layers=[ - SweepLayer('spi_rack.module1.dac0.voltage', 0.0, 0.1, 500, 0.01), + SweepLayer('spi_rack.module1.dac0.voltage', 0.0, 0.2, 100, 0.01), + SweepLayer('spi_rack.module1.dac1.voltage', 0.0, 0.2, 100, 0.01) ], measure=lambda ih, sp: ih.read_buffer('agilent_right.volt') ) - future = self.experiment_thread.add_job( - AbstractSweep.do_sweep_job, - args=(sweep, self.instrument_handler), + future = self.experiment_handler.do_sweep( + sweep=sweep, + instrument_handler=self.instrument_handler, wait=False ) def check_result(): try: result = future.result(timeout=0) + except TimeoutError: ui.timer(0.1, check_result, once=True) + except Exception as e: self.debug_status.set_text(f"Error: {e}") + else: self.debug_status.set_text(f"Sweep complete: {len(result)} points") @@ -415,4 +411,4 @@ def on_abort(self): def on_shutdown(self): self.abort_signal.set() # Abort any currently running experiments. self.instrument_handler.shutdown_instruments() - self.experiment_thread.join() \ No newline at end of file + self.experiment_handler.experiment_thread.join() \ No newline at end of file diff --git a/src/main.py b/src/main.py index 29c5e7b..59ee314 100644 --- a/src/main.py +++ b/src/main.py @@ -2,33 +2,49 @@ File: main.py Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) -Entry point to the auto tuner. This +Entry point to the auto tuner. ''' from nicegui import app, ui from gui import tuner_gui from tunerlog import TunerLog -gui : tuner_gui -logger : TunerLog + +gui = None +logger = None @app.on_startup def start_tuner_gui(): global gui, logger + logger = TunerLog("main") - logger.info("Starting the GUI...") + logger.info("Starting GUI...") + gui = tuner_gui() + print("Gui Startup Complete! Welcome to the Quantum Device Autotuner!") + @app.on_shutdown def stop_tuner_gui(): - logger.warning("Stopping the GUI...") - global gui - gui.on_shutdown() + global gui, logger + + if logger is not None: + logger.warning("Stopping the GUI...") + + if gui is not None: + gui.on_shutdown() @ui.page('/') def tuner_gui_root_page(): - global gui - logger.debug("Defining the server root page") + global gui, logger + + if gui is None: + ui.label("GUI is still starting... please refresh shortly") + return + + if logger is not None: + logger.debug("Defining the server root page") + gui.root_page() ui.run(port = 8081) From 43b1891a62f07b2d9787de9af92c36273b8df8c3 Mon Sep 17 00:00:00 2001 From: VanOschB <118694763+VanOschB@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:54:49 -0400 Subject: [PATCH 18/36] Second Autotuning + Sweep Commit Added methods to experiment_base for setting voltage configurations without taking data. Added an additional test button to the gui to test different sweeps and queues. Added some fitting functions to data_analysis to be linked to the autotuning_protocol file. --- src/Intel-tuner.ipynb | 10 - src/autotuning_protocol.py | 2 +- src/data_analysis.py | 1759 ++++++++++++++++++++++++++++-------- src/experiment_base.py | 79 +- src/gui.py | 44 +- 5 files changed, 1475 insertions(+), 419 deletions(-) delete mode 100644 src/Intel-tuner.ipynb diff --git a/src/Intel-tuner.ipynb b/src/Intel-tuner.ipynb deleted file mode 100644 index 21c2679..0000000 --- a/src/Intel-tuner.ipynb +++ /dev/null @@ -1,10 +0,0 @@ -{ - "cells": [], - "metadata": { - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/src/autotuning_protocol.py b/src/autotuning_protocol.py index 5b7c40b..81067c1 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -48,7 +48,7 @@ def pinch_off(): def barrier_barrier_sweep(): pass - def set_plunger_sweep(): + def coulomb_blockade_sweep(): pass def coulomb_diamonds(): diff --git a/src/data_analysis.py b/src/data_analysis.py index 563f343..b3fe4c9 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -1,459 +1,1454 @@ -# Import modules - -import yaml, datetime, sys, time, os, shutil, json,re -from pathlib import Path - +# Standard library +import datetime import inspect +import json +import logging +import os +import re +import shutil +import sys +import threading +import time +from pathlib import Path +from typing import Callable, Dict, List +# Third-party libraries +import numpy as np +import numpy.typing as npt import pandas as pd +import cv2 +import signal -import numpy as np +import matplotlib.cm as cm +import matplotlib.pyplot as plt +import matplotlib.lines as mlines +from matplotlib.ticker import AutoMinorLocator +from matplotlib.patches import ConnectionPatch -import scipy as sp -from scipy.ndimage import convolve +from scipy.optimize import curve_fit +from scipy.ndimage import convolve, map_coordinates, gaussian_filter1d -import matplotlib.pyplot as plt -import matplotlib.cm as cm +import skimage +from skimage import filters, transform +from skimage.feature import canny +from skimage.filters import threshold_otsu, sato +from skimage.morphology import diamond, rectangle # noqa +from skimage.transform import probabilistic_hough_line -from typing import List, Dict, Callable +import yaml +from colorlog import ColoredFormatter import qcodes as qc from qcodes.dataset import AbstractSweep, Measurement from qcodes.dataset.dond.do_nd_utils import ActionsT from qcodes.parameters import ParameterBase -import numpy.typing as npt -import skimage -from skimage.transform import probabilistic_hough_line -from skimage.feature import canny -from skimage.filters import threshold_otsu -from skimage.morphology import diamond, rectangle # noqa +from nicegui import ui + +def logarithmic(x, a, b, x0, y0): + return a * np.log(b*(x-x0)) + y0 -import logging -from colorlog import ColoredFormatter -import sys +def exponential(x, a, b, x0, y0): + return a * np.exp(b * (x-x0)) + y0 -from nicegui import ui -import threading +def sigmoid(x, a, b, x0, y0): + return a/(1+np.exp(b * (x-x0))) + y0 + +def linear(x, m, b): + return m * x + b + +def relu(x, a, x0, b): + return np.maximum(0, a * (x - x0) + b) + +def fit_to_function(x_data, + y_data, + function: Callable): + + popt, pcov = curve_fit(function, x_data, y_data) + perr = np.sqrt(np.diag(pcov)) + params = list(inspect.signature(function).parameters.keys())[1:] -class DataAnalysis: + for name, val, err in zip(params, popt, perr): + print(f"{name} = {val:.3f} ± {err:.3f}") + + return params, popt, pcov + +def pinch_off_curve_ranges(x_data, y_data): + + # --- Data definitions --- + + x1 = np.array(x_data) + y1 = np.array(y_data) + + # --- Fit sigmoids --- + + p0_1 = [min(y1), max(y1), np.median(x1), 0.05] + params, popt, pcov = fit_to_function(x1, y1, sigmoid) + + # --- Extract key points --- + + A, B, V0, dV = popt + + # Define the characteristic voltage range as (V0 ± √8 * dV) + + range_factor = np.sqrt(8) + + pinch_off = V0 - range_factor * dV + sat = V0 + range_factor * dV - def __init__(self, - logger, - tuner_config) -> None: + # --- Plot data --- + + fig, ax = plt.subplots(figsize=(8,6)) + ax.plot(x1, y1, '-', color='C0', linewidth=2, label='I ($V_{B1}$)') + ax.legend(fontsize=24, frameon=False, loc='upper right') + + # --- Double-sided arrows showing full range (swapped positions) --- + + # Define arrow y-positions (swap positions) + + y_arrow1 = ax.get_ylim()[1] + 0.05 # Device 1 arrow ABOVE + y_arrow2 = ax.get_ylim()[0] - 0.01 # Device 2 arrow BELOW + + # Device 1 arrow (now above) + + ax.annotate( + '', xy=(sat, y_arrow1), xytext=(pinch_off, y_arrow1), + arrowprops=dict(arrowstyle='<->', color='C0', lw=3.0, shrinkA=0, shrinkB=0), + annotation_clip=False + ) + ax.text((sat + pinch_off)/2, y_arrow1 - 0.05*(ax.get_ylim()[1]-ax.get_ylim()[0]), + s='', color='C0', ha='center', va='top', fontsize=20) + + # --- Characteristic vertical lines extending exactly to the data points --- + + # Compute corresponding y-values from the *fitted sigmoid* (smooth, reliable) + + y_pinch1 = sigmoid(pinch_off, *popt) + y_sat1 = sigmoid(sat, *popt) + + for color, po, sat, label, y_arrow, direction, y_pinch, y_sat in [ + # Device 1 → arrow above, extend down to data + ('C0', pinch_off, sat, 'Device 1', y_arrow1, 'down', y_pinch1, y_sat1) + ]: + if direction == 'up': + # Extend upward from arrow to the y-values of the fitted curve + ax.vlines(po, ymin=y_arrow, ymax=y_pinch - 0.01, colors=color, linestyles='--', alpha=0.6) + ax.vlines(sat, ymin=y_arrow, ymax=y_sat - 0.025, colors=color, linestyles='--', alpha=0.6) + else: + # Extend downward from arrow to the y-values of the fitted curve + ax.vlines(po, ymin=y_pinch + 0.02, ymax=y_arrow, colors=color, linestyles='--', alpha=0.6) + ax.vlines(sat, ymin=y_sat - 0.015, ymax=y_arrow, colors=color, linestyles='--', alpha=0.6) + + # --- Overlay fitted sigmoid curves --- + + V_fit = np.linspace(x1.min(), x1.max(), 500) + + # Fitted curves for each device + + y_fit1 = sigmoid(V_fit, *popt) + y_fit2 = sigmoid(V_fit, *popt) + + # --- Labels and formatting --- + + ax.set_xlabel(r'V$_{B1}$, V$_{B2}$ (V)', fontsize=45) + ax.set_ylabel('I (nA)', fontsize=55) + + ax.minorticks_on() + ax.tick_params(which='minor', direction='in', length=3, top=True, right=True) + ax.tick_params(direction='in', length=5, width=1.2, labelsize=18, top=True, right=True) + + ax.set_xticks([-2.5, -2.0, -1.5, -1.0, -0.5]) + ax.set_xticklabels(['-2.5', '', '', '', '-0.5'], fontsize=45) + + ax.set_yticks([0.0, 0.4, 0.8, 1.2]) + ax.set_yticklabels(['0.0', '', '', '1.2'], fontsize=45) + + # Extend y-limits slightly to make space for arrows + + ax.set_ylim(-0.1, ax.get_ylim()[1]) + + plt.tight_layout() + plt.show() + + # --- Print summary --- + + print(f" Saturation Voltage: {sat:.3f} V") + print(f" Midpoint Voltage: {V0:.3f} V") + print(f" Pinch-off Voltage: {pinch_off:.3f} V\n") + + pass + +def extract_max_conductance_points(self, x_data, y_data): + + x1 = np.array(x_data) + y1 = np.array(y_data) + + # Plot + plt.figure(figsize=(8,6)) + plt.plot(x1, y1) + plt.xlabel('V_P (V)') + plt.ylabel('Current (nA)') + plt.title('Coulomb Blockade For P-Type Device') + + plt.show() + + # Now, we calculate the derivative and replot + + dIdV = np.gradient(y1, x1) + + posdIdV = abs(dIdV) + + # Plot + plt.figure(figsize=(8,6)) + plt.plot(x1, posdIdV) + plt.xlabel('V_P (mV)') + plt.ylabel('Conductance (nS)') + plt.title('Conductance Peaks for P-Type Device') + + plt.show() + + # --- Find two largest and two smallest conductance points (positive + negative extremes) --- + + # Get indices of top 2 positive conductance values + top_idx_pos = np.argsort(dIdV)[-2:] + + # Get indices of bottom 2 negative conductance values + top_idx_neg = np.argsort(dIdV)[:2] + + # Combine them and sort by x-position for consistent plotting + top_idx = np.sort(np.concatenate([top_idx_pos, top_idx_neg])) + + # Extract the corresponding data points + x_top = x1.iloc[top_idx] + I_top = y1.iloc[top_idx] + G_top = dIdV[top_idx] + + # Create two subplots that share the x-axis + fig, (ax1, ax2) = plt.subplots(2, 1, sharex=False, figsize=(8, 6)) + + # --- Top panel: Current --- + ax1.plot(x1, y1, color='#2c5aa0', linewidth=1) + ax1.scatter(x_top, I_top, facecolors='none', edgecolors='#FF5500', s=100, linewidths=2, zorder=5, label='High Sensitivity Points') + ax1.set_ylabel('I (nA)', fontsize=45) + ax1.set_ylim(bottom=0) + ax1.set_xlim(min(x1) - 0.05, max(x1) + 0.05) + ax1.tick_params(labelbottom=True) + + # --- Bottom panel: Conductance --- + ax2.plot(x1, posdIdV, color='#2c5aa0', linewidth=1) + ax2.scatter(x_top, G_top, facecolors='none', edgecolors='#FF5500', s=100, linewidths=2, zorder=5, label='Max G') + ax2.set_xlabel(r'$V_P$ (V)', fontsize=45) + ax2.set_ylabel('G (nS)', fontsize=45) + ax2.set_xlim(min(x1) - 0.05, max(x1) + 0.05) + + # --- Create the connection line --- + con = ConnectionPatch( + xyA=(x_top, I_top), coordsA=ax1.transData, + xyB=(x_top, G_top), coordsB=ax2.transData, + color='#FF5500', linestyle='--', linewidth=0.7 + ) + fig.add_artist(con) + + # --- Create a custom legend entry (hollow circle) --- + legend_marker = mlines.Line2D([], [], color='#FF5500', marker='o', + markerfacecolor='none', markersize=10, + linewidth=0, label='High Sensitivity Points') + + # --- Custom tick labels: only min and max shown --- + + # Get existing ticks (so tick marks stay) + for ax in [ax1, ax2]: + + ax.minorticks_on() + ax.tick_params(which='minor', direction='in', length=3, top=True, right=True) + ax.tick_params(direction='in', length=5, width=1.2, labelsize=20, top=True, right=True) + xticks = ax.get_xticks() + yticks = ax.get_yticks() - self.logger = logger + ax1.set_xticks([-0.4, 0.0, 0.4]) + ax1.set_xticklabels(['-0.4', '0.0', '0.4'], fontsize=25) - self.tuner_info = yaml.safe_load(Path(tuner_config).read_text()) + ax1.set_yticks([0.0, 0.15]) + ax1.set_yticklabels(['0.0', '0.15'], fontsize=25) - self.model_path = self.tuner_info['barrier_barrier']['segmentation_model_path'] - self.model_config_path = self.tuner_info['barrier_barrier']['segmentation_model_config_path'] - self.model_name =self.tuner_info['barrier_barrier']['segmentation_model_name'] - self.model_processor = self.tuner_info['barrier_barrier']['segmentation_model_processor'] - self.confidence_threshold = self.tuner_info['barrier_barrier']['segmentation_confidence_threshold'] - self.polygon_threshold = self.tuner_info['barrier_barrier']['segmentation_polygon_threshold'] - self.segmentation_class = self.tuner_info['barrier_barrier']['segmentation_class'] - - def logarithmic(self, x, a, b, x0, y0): - return a * np.log(b*(x-x0)) + y0 + ax2.set_xticks([-0.4, 0.0, 0.4]) + ax2.set_xticklabels(['-0.4', '0.0', '0.4'], fontsize=25) + + ax2.set_yticks([0.0, 10]) + ax2.set_yticklabels(['0', '10'], fontsize=25) + + ax1.legend(handles=[legend_marker], loc='upper left', fontsize=16, frameon=False) + + # --- Adjust layout --- + plt.subplots_adjust(hspace=0.40) + plt.show() + +def extract_bias_point( + lb_data: np.array, + rb_data: np.array, + current_data: np.array, + minAngleDeg: float = -55, + maxAngleDeg: float = -35, + minLineLength: int = 50, + maxLineGap: int = 250, + debug: bool = False, + plot_results: bool = True) -> list[tuple]: + + # We start by ensuring our inputs are numpy arrays - def exponential(self, x, a, b, x0, y0): - return a * np.exp(b * (x-x0)) + y0 + lb_data = np.array(lb_data) + rb_data = np.array(rb_data) + current_data = np.array(current_data) + + # Now, we reshape the data into an array + + if current_data.ndim == 1: + nx = len(np.unique(lb_data)) + ny = len(np.unique(rb_data)) + current_data = current_data.reshape((ny, nx)) + + ny, nx = current_data.shape + + # Here, we define the voltage ranges + + lb_voltages = np.linspace(lb_data.min(), lb_data.max(), nx) + rb_voltages = np.linspace(rb_data.min(), rb_data.max(), ny) - def sigmoid(self, x, a, b, x0, y0): - return a/(1+np.exp(b * (x-x0))) + y0 - def linear(self, x, m, b): - return m * x + b + # ---------- Gradient Calculation and Ridge Detection ---------- - def relu(self, x, a, x0, b): - return np.maximum(0, a * (x - x0) + b) - def fit_to_function(self, - x_data, - y_data, - function: Callable): + # Now, compute the gradient and the log of the gradient + + Gx, Gy = np.gradient(current_data) + G = (1.0 / np.sqrt(2.0)) * np.sqrt(Gx**2 + Gy**2) + + g_lo, g_hi = np.percentile(G, [2, 98]) + G_clipped = np.clip(G, g_lo, g_hi) + G_scaled = (G_clipped - g_lo) / (g_hi - g_lo) + + G_uint = (255 * G_scaled).astype(np.uint8) + + low = int(0.10 * 255) # discard noise + high = int(0.35 * 255) # discard strongest boundaries + + # These next lines threshold above and below to keep a certain color band + + _, low_passed = cv2.threshold(G_uint, 10, low, cv2.THRESH_TOZERO) + _, band_passed = cv2.threshold(low_passed, high, 255, cv2.THRESH_TOZERO_INV) + + # Here, we apply ridge detection, meaning we are detecting peaks within the image, then finding the middles of those peaks, widthwise + + epsilon = 1e-12 + + ridge = sato(band_passed, sigmas=[1, 2, 3], black_ridges=False) + ridge_norm = (ridge - ridge.min()) / (np.ptp(ridge) + epsilon) + ridge_filtered = ridge_norm > 0.20 + + # Now, we limit our analysis to the bottom left-quadrant + + ridge_masked = np.zeros_like(ridge_filtered) + ridge_masked[:ny // 2, :nx // 2] = ridge_filtered[:ny // 2, :nx // 2] + + # From these edges, we detect lines using a probabilistic hough transform + + lines = transform.probabilistic_hough_line( + ridge_masked, + threshold=15, + line_length=max(2, int(minLineLength * 0.1)), + line_gap=max(1, int(maxLineGap * 0.02)) + ) + + if not lines: + return [] + + # Now, we filter for lines within a certain angle range + + filtered_lines = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + if minAngleDeg <= angle <= maxAngleDeg: + filtered_lines.append((*p0, *p1)) + + if not filtered_lines: + return [] + + # Previously, we limited analysis to the bottom left quadrant, here we're defining the voltage range for that quadrant + + lb_mid_volt = 0.5 * (lb_data.min() + lb_data.max()) + rb_mid_volt = 0.5 * (rb_data.min() + rb_data.max()) + + perp_candidates = [] + perp_traces_for_plot = [] + + perp_length_pixels = max(40, int(min(nx, ny) * 0.5)) + perp_samples = 400 + smooth_sigma = 2.0 + + x_index_arr = np.arange(nx) + y_index_arr = np.arange(ny) + + # Now, for each filtered line, we define a line perpendicular to it, then find the peaks in current along them + + for x1, y1, x2, y2 in filtered_lines: - popt, pcov = sp.optimize.curve_fit(function, x_data, y_data) - perr = np.sqrt(np.diag(pcov)) + # midpoints + mx = 0.5 * (x1 + x2) + my = 0.5 * (y1 + y2) + + # distances + dx, dy = x2 - x1, y2 - y1 + L = np.hypot(dx, dy) + if L == 0: + continue + + # perpendicular direction + pxu, pyu = -dy / L, dx / L + + # length along the perpendicular lines in pixel space + t = np.linspace(-perp_length_pixels / 2, + perp_length_pixels / 2, + perp_samples) + + # Limiting the values of the array to the bottom left quadrant + samp_x = np.clip(mx + pxu * t, 0, nx - 1) + samp_y = np.clip(my + pyu * t, 0, ny - 1) + trace_id = len(perp_traces_for_plot) + + # defining current + trace = map_coordinates( + current_data, + [samp_y, samp_x], + order=3, + mode="reflect" + ) + + # smooth the trace + trace_smooth = gaussian_filter1d(trace, smooth_sigma) + + # Calculating conductance + ds = np.sqrt(np.diff(samp_x)**2 + np.diff(samp_y)**2) + s = np.concatenate([[0.0], np.cumsum(ds)]) + conductance = np.gradient(trace_smooth, s) + + trace_info = { + "px": samp_x.copy(), + "py": samp_y.copy(), + "trace_id": trace_id, + "trace": trace_smooth.copy(), + "conductance": conductance.copy(), + "s": s.copy() + } + + # find local maxima of current + noise_sigma = 1.4826 * np.median(np.abs(trace_smooth - np.median(trace_smooth))) + prominence_thresh = 4.0 * noise_sigma + peaks, _ = signal.find_peaks(trace_smooth, + prominence=prominence_thresh, + distance=15) + + if len(peaks) == 0: + continue + + # defining the the maxima in voltage space from pixel space + peak_idx = peaks + px = samp_x[peak_idx] + py = samp_y[peak_idx] + + vx = np.interp(px, x_index_arr, lb_voltages) + vy = np.interp(py, y_index_arr, rb_voltages) + + # bottom-left quadrant restriction + valid = (vx < lb_mid_volt) & (vy < rb_mid_volt) + peak_idx = peak_idx[valid] + px = px[valid] + py = py[valid] + vx = vx[valid] + vy = vy[valid] + + if len(peak_idx) == 0: + continue + + # compiling data into trace info + for k, p in enumerate(peak_idx): + prom = signal.peak_prominences(trace_smooth, peak_idx)[0] + score = prom[k] + + perp_candidates.append( + ( + score, + vx[k], + vy[k], + px[k], + py[k], + trace_id + ) + ) + + trace_info["peak_idx"] = peak_idx.copy() + trace_info["vx_peak"] = vx + trace_info["vy_peak"] = vy + + perp_traces_for_plot.append(trace_info) + + if not perp_candidates: + return [] + + + # ---------- Selecting Final Bias Points ---------- + + + # First, we sort the points in order of increasing current + + perp_candidates.sort(key=lambda x: -x[0]) + + # Then, we pick the top 4 points of highest current - params = list(inspect.signature(function).parameters.keys())[1:] + N_FINAL = 4 + top_candidates = perp_candidates[:N_FINAL] - for name, val, err in zip(params, popt, perr): - print(f"{name} = {val:.3f} ± {err:.3f}") + selected_trace_ids = {c[5] for c in top_candidates} - return params, popt, pcov + selected_peaks_by_trace = { + c[5]: (c[1], c[2]) for c in top_candidates + } - def gradient(self): - pass + for tr in perp_traces_for_plot: + tid = tr["trace_id"] + if tid in selected_peaks_by_trace: + tr["peaks"] = [selected_peaks_by_trace[tid]] + perp_bias_points = [ + (round(vx, 3), round(vy, 3)) + for (_, vx, vy, _, _, _) in top_candidates + ] + + perp_traces_for_plot = [ + tr for tr in perp_traces_for_plot + if tr["trace_id"] in selected_trace_ids + ] + + # Now, we overlay perpendicular traces (strictly clipped to BL quadrant) + for tr in perp_traces_for_plot: + vx = np.interp(tr["px"], x_index_arr, lb_voltages) + vy = np.interp(tr["py"], y_index_arr, rb_voltages) + + in_quad = (vx < lb_mid_volt) & (vy < rb_mid_volt) + if not np.any(in_quad): + continue + + idx = np.where(in_quad)[0] + splits = np.where(np.diff(idx) > 1)[0] + blocks = np.split(idx, splits + 1) + + peak_idx = tr.get("peak_idx", None) + chosen_block = None + if peak_idx is not None: + for b in blocks: + if np.intersect1d(peak_idx, b).size > 0: + chosen_block = b + break + if chosen_block is None: + chosen_block = blocks[0] + + tr["chosen_block"] = chosen_block + + + # ---------- Final Plotting ---------- + - def extract_bias_point(self, - data: pd.DataFrame, - plot_process: bool, - axes: plt.Axes): + if plot_results: + + # Create figure and axes + fig, ax = plt.subplots(figsize=(10,10)) + + # Show image + im = ax.imshow( + current_data, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) + + # Set axis limits + ax.set_xlim(lb_data.min(), lb_data.max()) + ax.set_ylim(rb_data.min(), rb_data.max()) + + # Round ticks + step = 0.001 + def round_to_step(x, step): return step * np.round(x / step) + + x0, x1 = round_to_step(lb_data.min(), step), round_to_step(lb_data.max(), step) + y0, y1 = round_to_step(rb_data.min(), step), round_to_step(rb_data.max(), step) - # Inference image for anything above 0.5 - outputs, metadata, image, Xdata, Ydata = inference( - data, - self.model_path, - self.model_config_path, - self.model_name, - self.model_processor, - self.confidence_threshold, - self.polygon_threshold, - plot=plot_process + ax.set_xticks([0.65, 0.85]) + ax.set_yticks([0.60, 0.70]) + ax.set_xticklabels(["0.65", "0.85"], fontsize=40) + ax.set_yticklabels(["0.60", "0.70"], fontsize=40) + + ax.tick_params( + which="major", + direction="in", + length=6, + width=1.2, + top=True, + right=True ) - # Only keep things with class 'CD' = 'Central Dot' - outputs = outputs[outputs.pred_classes == metadata.thing_classes.index(self.segmentation_class)] + ax.minorticks_on() - # Get the bounding box with the best score - bboxes = outputs.pred_boxes.tensor.numpy() - max_score_index = np.argmax(outputs.scores) - best_bbox = bboxes[max_score_index] - best_score = outputs.scores[max_score_index] + ax.xaxis.set_minor_locator(AutoMinorLocator(9)) + ax.yaxis.set_minor_locator(AutoMinorLocator(9)) - bbox_units = pixel_polygon_to_image_units(best_bbox, data) - x, y = bbox_units[:,0], bbox_units[:,1] - x1,x2 = x - y1,y2 = y + # Style minor ticks (no labels by default) + ax.tick_params( + which="minor", + direction="in", + length=3, + width=1.0, + top=True, + right=True + ) - if plot_process: - - # Plot the bounding box + # Axis labels + ax.set_xlabel(r'V$_{B2}$ (V)', fontsize=45, labelpad = -35) + ax.set_ylabel(r'V$_{B1}$ (V)', fontsize=45) + + ax.yaxis.set_label_coords(-0.025, 0.40) + + # Create horizontal colorbar above the axes + from mpl_toolkits.axes_grid1.inset_locator import inset_axes + cax = inset_axes( + ax, + width="100%", + height="50%", + loc="upper center", + bbox_to_anchor=(0, 1.08, 1, 0.1), + bbox_transform=ax.transAxes, + borderpad=0 + ) + cbar = plt.colorbar(im, cax=cax, orientation="horizontal") + cbar.set_label("I (nA)", fontsize=35, labelpad=10) + cbar.ax.xaxis.set_ticks_position("bottom") + cbar.ax.xaxis.set_label_position("top") + cbar.set_ticks([0.0, 0.25, 0.50, 0.75, 1.0]) + cbar.set_ticklabels(['0', '', '', '', '1']) + cbar.ax.tick_params(labelsize=30, direction="in", length=6) + + cbar.ax.minorticks_on() + + cbar.ax.xaxis.set_minor_locator(AutoMinorLocator(5)) + + # Style minor ticks + cbar.ax.tick_params( + which="minor", + direction="in", + length=4, + width=1.0 + ) + + # Block Boundary + + # Top side: horizontal line from left-mid to right-mid + ax.plot( + [lb_mid_volt, lb_data.min()], # x: left → right + [rb_mid_volt, rb_mid_volt], # y constant at top + linestyle='--', + color='red', + linewidth=1.2, + alpha=0.9 + ) + + # Right side: vertical line from bottom-mid to top-mid + ax.plot( + [lb_mid_volt, lb_mid_volt], # x constant at right + [rb_data.min(), rb_mid_volt], # y: bottom → top + linestyle='--', + color='red', + linewidth=1.2, + alpha=0.9 + ) + + rect = Rectangle( + (lb_data.min(), rb_data.min()), # bottom-left corner + lb_mid_volt - lb_data.min(), # width + rb_mid_volt - rb_data.min(), # height + facecolor='red', + alpha=0.2, # set opacity here (1.0 = fully opaque) + edgecolor=None, + zorder=2 + ) + + ax.add_patch(rect) + + # Hough lines + for x1, y1, x2, y2 in filtered_lines: + # compute voltage coordinates + v1x = np.interp(x1, x_index_arr, lb_voltages) + v1y = np.interp(y1, y_index_arr, rb_voltages) + v2x = np.interp(x2, x_index_arr, lb_voltages) + v2y = np.interp(y2, y_index_arr, rb_voltages) - axes.plot([x1, x2], [y1, y1], linewidth=3, alpha=0.5, linestyle='--', color='k') # Top line - axes.plot([x1, x2], [y2, y2], linewidth=3, alpha=0.5, linestyle='--', color='k') # Bottom line - axes.plot([x1, x1], [y1, y2], linewidth=3, alpha=0.5, linestyle='--', color='k') # Left line - axes.plot([x2, x2], [y1, y2], linewidth=3, alpha=0.5, linestyle='--', color='k') # Right line - label_text = 'CD' +' ' + str(round(best_score.item() * 100)) + "%" - axes.text(x1, y2, label_text, color='k', fontsize=10, verticalalignment='bottom') - - voltage_window = {Xdata.name.split('_')[-1]: (x1,x2), Ydata.name.split('_')[-1]:(y1,y2)} - print(f"Suggested voltage window: {voltage_window}") - - range_X = voltage_window[Xdata.name.split('_')[-1]] - range_Y = voltage_window[Ydata.name.split('_')[-1]] - windowed_data = data[ - (data[Xdata.name] >= range_X[0]) & (data[Xdata.name] <= range_X[1]) & - (data[Ydata.name] >= range_Y[0]) & (data[Ydata.name] <= range_Y[1]) - ] - - window_data_image, Xdata, Ydata = convert_data_to_image(windowed_data) - window_data_image = window_data_image[:,:,0] - edges = canny(window_data_image,sigma=0.5, low_threshold=0.1*np.iinfo(np.uint8).max, high_threshold=0.3 * np.iinfo(np.uint8).max) - lines = probabilistic_hough_line(edges, threshold=0, line_length=3, - line_gap=0) + # Uncomment below to see the detected Hough lines + ax.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) + + # Perpendicular traces and peaks + for tr in perp_traces_for_plot: + idx = tr.get("chosen_block", None) + if idx is None or len(idx) == 0: continue + vx = np.interp(tr["px"], np.arange(nx), lb_voltages) + vy = np.interp(tr["py"], np.arange(ny), rb_voltages) + ax.plot(vx[idx], vy[idx], c='yellow', lw=1.5, alpha=0.9) + valid_peaks = np.intersect1d(tr.get("peak_idx", []), idx) + ax.scatter(vx[valid_peaks], vy[valid_peaks], s=150, c='white', + marker='*', edgecolors='black', zorder=10) + + ax.set_box_aspect(0.775) + + plt.show() + + # These are 1D perpendicular trace plots + + if debug: + for tr in perp_traces_for_plot: + s = tr["s"] + I = tr["trace"] # smoothed current + dIds = tr["conductance"] # dI/ds + peak_idx = tr.get("peak_idx", []) + chosen_block = tr.get("chosen_block", None) + + fig, axs = plt.subplots( + 2, 1, figsize=(7, 5), sharex=True + ) + + # We plot the current traces here + + axs[0].plot(s, I, color="black", lw=1.3) + axs[0].set_ylabel("Current") + axs[0].set_title( + f"Perpendicular trace {tr['trace_id']}" + ) + + # Mark current peaks + + if len(peak_idx) > 0: + axs[0].scatter( + s[peak_idx], + I[peak_idx], + c="red", + s=40, + zorder=5, + label="Current peaks" + ) + + # Shade chosen block (if present) + + if chosen_block is not None and len(chosen_block) > 0: + axs[0].axvspan( + s[chosen_block[0]], + s[chosen_block[-1]], + color="orange", + alpha=0.15, + label="Selected block" + ) + + axs[0].legend(loc="best") + + # Here, we plot the conductance as well + + axs[1].plot(s, dIds, color="tab:blue", lw=1.2) + axs[1].set_xlabel("Arc length s (pixels)") + axs[1].set_ylabel("dI/ds") + + plt.tight_layout() + plt.show() + + + # ---------- Debugging Code ---------- + + + if debug: - if plot_process: - # Generating figure 2 - fig, ax = plt.subplots(1, 3, figsize=(15, 5)) - ax = ax.ravel() - - ax[0].imshow(window_data_image, cmap=cm.gray, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()],) - ax[0].set_title('Input image') - - ax[1].imshow(edges, cmap=cm.gray, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()],) - ax[1].set_title('Masked Canny edges') - - potential_points = {} - angles_data = [] - slopes_data = [] - for line in lines: - p0_pixel, p1_pixel = line - p0, p1 = pixel_polygon_to_image_units(line, windowed_data) - - dy = (p1[1]-p0[1]) - dx = (p1[0]-p0[0]) - if dx == 0: - continue - m = dy/dx - theta = np.arctan(m)*(180/np.pi) - if theta > -40 or theta < -50: - continue - angles_data.append(theta) - slopes_data.append(m) - midpoint_pixel = (np.array(p0_pixel) + np.array(p1_pixel))/2 - midpoint_units = (np.array(p0) + np.array(p1))/2 - # print(midpoint) - midpoint_pixel = midpoint_pixel.astype(int) - - X_name,Y_name,Z_name = windowed_data.columns[:3] - current_at_midpoint = windowed_data[Z_name].to_numpy().reshape(len(Xdata), len(Ydata))[midpoint_pixel[0],midpoint_pixel[1]] - potential_points[tuple(midpoint_units)] = current_at_midpoint - - if plot_process: - ax[1].plot((p0[0], p1[0]), (p0[1], p1[1])) - ax[1].scatter([midpoint_units[0]],[midpoint_units[1]], marker='*',s=50) - ax[0].plot((p0[0], p1[0]), (p0[1], p1[1])) - ax[0].scatter([midpoint_units[0]],[midpoint_units[1]], marker='*',s=50) - - if plot_process: - ax[1].set_title('Hough Transform') - ax[1].set_axis_off() - - ax[1].set_title('Histogram of Detected Line Angles') - ax[2].hist(angles_data, bins=2*int(np.sqrt(len(slopes_data)))) - ax[2].set_xlabel(r"$\theta^\circ$") - ax[2].set_ylabel(r"$f$") - - max_key = np.array(max(potential_points, key=potential_points.get)) - bias_point = {Xdata.name.split('_')[-1]: max_key[0], Ydata.name.split('_')[-1]: max_key[1]} - axes.scatter(*max_key, marker='*', s=30, c='k', label='Bias Point') - axes.legend(loc='best') - print(f"Suggested bias point: {bias_point}") - - return bias_point, voltage_window - - def extract_max_conductance_point(self, - data: pd.DataFrame, - plot_process: bool = False, - sigma: float = 0.5) -> dict: - - V_name, I_name = data.columns - - data = data.rename( - columns={I_name: '{}_current'.format(I_name.split('_')[0])} - ) - data.iloc[:,-1] = data.iloc[:,-1].subtract(0).mul(1e-7) # sensitivity + # We first plot the original current data + + plt.figure(figsize=(10, 6)) + plt.imshow(current_data, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) + plt.xlabel(r'V$_{B2}$ (V)', fontsize=45) + plt.ylabel(r'V$_{B1}$ (V)', fontsize=45) + plt.title("Original Current Data") + plt.show() - V_name, I_name = data.columns + # Next, we plot the gradient of the data, i.e. the conductance + + plt.figure(figsize=(10, 6)) + plt.imshow(G, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) + plt.title("G (Conductance)") + plt.show() - I_data = data[I_name] - V_data = data[V_name] + # Then, we plot G_log normalized to 255, or G_uint - I_filtered = sp.ndimage.gaussian_filter1d( - I_data, sigma + plt.figure(figsize = (10, 6)) + plt.imshow(G_uint, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' ) + plt.title(r"$G Normalized$") + plt.show() - G_data = np.gradient(I_filtered, np.abs(V_data.iloc[-1] - V_data.iloc[-2])) - G_filtered = sp.ndimage.gaussian_filter1d( - G_data, sigma + # Here is the plot of the band-passed G_uint, i.e. after being thresholded + + plt.figure(figsize = (10, 6)) + plt.imshow(band_passed, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' ) + plt.title(r"$G Normalized and Thresholded$") + plt.show() - threshold = max(G_filtered) / 10 - - maxima = sp.signal.argrelextrema(G_filtered, np.greater)[0] - maxima_indices = maxima[G_filtered[maxima] >= threshold] - - if len(maxima_indices) != 0: - results = dict(zip(V_data.iloc[maxima_indices], G_filtered[maxima_indices])) - - results_sorted = dict(sorted(results.items(), key=lambda item: item[1])) - - if plot_process: - - # Create figure and axes - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), sharex=True) - - # Plot for I_SD vs V_ST - ax1.set_title(r"$I_{SD}$") - ax1.set_ylabel(r"$I_{SD}\ (A)$") - ax1.plot(V_data, I_data, 'k-', alpha=0.3,linewidth=0.75) - ax1.plot(V_data, I_filtered, 'k-', linewidth=1.5) - - # Plot for G vs V_ST - ax2.set_title(r"$G_{SD}$") - ax2.set_ylabel(r"$G_{SD}\ (S)$") - ax2.set_xlabel(r"$V_{P}\ (V)$") - ax2.plot(V_data, G_data, 'k-', alpha=0.3, linewidth=0.75) - ax2.plot(V_data, G_filtered, 'k-', linewidth=1.5) - ax2.hlines(y=threshold, xmin=V_data.iloc[0], xmax=V_data.iloc[-1], color='black', linestyle='--', linewidth=1.5) - - def legend_without_duplicate_labels(ax): - # Helper function to prevent duplicates in the legend. - handles, labels = ax.get_legend_handles_labels() - unique = [(h, l) for i, (h, l) in enumerate(zip(handles, labels)) if l not in labels[:i]] - ax.legend(*zip(*unique)) - - # Plot all the points of interest according to their sensitivity - if len(maxima_indices) > 0: - for i in maxima_indices: - - index = list(results_sorted.keys()).index(V_data.iloc[i]) - if index == len(results_sorted)-1: - label = "High Sensitivity" - color = 'b' - elif index == 0: - label = "Low Sensitivity" - color = 'r' - else: - label = "Medium Sensitivity" - color = 'g' - - if plot_process: - ax1.text(V_data.iloc[i], I_filtered[i], f'{V_data.iloc[i]:.2f} mV', color='black', fontsize=6, fontweight=750, ha='right', va='bottom', bbox=dict(facecolor='white', edgecolor='black', boxstyle='round,pad=0.5', alpha=0.25)) - ax2.scatter(V_data.iloc[i], G_filtered[i], c=color, label=label) - ax1.scatter(V_data.iloc[i], I_filtered[i], c=color, label=label) - ax1.legend(loc='best') - legend_without_duplicate_labels(ax1) - - V_P_high, G_high = list(results_sorted.items())[-1] - V_P_med, G_med = list(results_sorted.items())[int(len(results_sorted.items())//2)] - V_P_low, G_low = list(results_sorted.items())[0] - - gate_name = V_name.split('_')[-1] - return {'high': {gate_name: V_P_high, 'conductance': G_high}, - 'medium': {gate_name: V_P_med, 'conductance': G_med}, - 'low': {gate_name: V_P_low, 'conductance': G_low}} - - def extract_lever_arms(self, - data: pd.DataFrame, - plot_process: bool = False) -> dict: - - # Load in data and seperate - X_name, Y_name, Z_name = data.columns - Xdata, Ydata = np.unique(data[X_name]), np.unique(data[Y_name]) + # Here is a plot of the ridges - df_pivoted = data.pivot_table(values=Z_name, index=Y_name, columns=X_name).fillna(0) - Zdata = df_pivoted.to_numpy() + plt.figure(figsize=(10, 6)) + plt.imshow(ridge, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) + plt.title("Ridges detected") + plt.show() + + # Here is a plot of the normalized ridges + + plt.figure(figsize=(10, 6)) + plt.imshow(ridge_norm, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) + plt.title("Normalized Ridges Detected") + plt.show() + + # Here is a plot of the ridges filtered for strength + + plt.figure(figsize=(10, 6)) + plt.imshow(ridge_masked, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) + plt.title("Filtered Ridges Detected") + plt.show() - # Calculate conductance where G = dI / dVp - G = np.gradient(Zdata)[1] - if plot_process: - plt.imshow(G, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) - plt.title("Transconductance") - plt.colorbar() - plt.show() + # ---------- Hough Line Plotting ---------- + + + # Now, we'll plot a set of lines from the Hough Transform at each preprocessing stage + + + # We start with lines detected from the original data + + plt.figure(figsize = (10, 6)) + plt.imshow( + current_data, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) + + lines = transform.probabilistic_hough_line( + current_data, + threshold=15, + line_length=max(2, int(minLineLength * 0.1)), + line_gap=max(1, int(maxLineGap * 0.02)) + ) + + if not lines: + return [] + + # Now, we filter for lines within a certain angle range + + filtered_lines = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + if minAngleDeg <= angle <= maxAngleDeg: + filtered_lines.append((*p0, *p1)) + + if not filtered_lines: + return [] + + for x1, y1, x2, y2 in filtered_lines: + hx = 0.5 * (x1 + x2) + hy = 0.5 * (y1 + y2) + + # Check if line midpoint is near any selected block peaks + keep_line = False + for tr in perp_traces_for_plot: + idx = np.arange(len(tr["s"])) + if idx is None or len(idx) == 0: + continue + valid_peaks = np.intersect1d(tr["peak_idx"], idx) + if len(valid_peaks) > 0: + mx = np.mean(tr["px"][valid_peaks]) + my = np.mean(tr["py"][valid_peaks]) + if np.hypot(hx - mx, hy - my) < max(perp_length_pixels, 5): + keep_line = True + break + + if keep_line: + v1x = np.interp(x1, x_index_arr, lb_voltages) + v1y = np.interp(y1, y_index_arr, rb_voltages) + v2x = np.interp(x2, x_index_arr, lb_voltages) + v2y = np.interp(y2, y_index_arr, rb_voltages) + plt.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) - # Apply filter to bring out edges better - def U(x,y): - sigX, sigY = 5,5 - return (1/(2 * np.pi * sigX * sigY)) * np.exp(- 0.5* ((x/sigX)**2 + (y/sigY)**2)) - def adjusted(G,G0): - return np.sign(G) * np.log((np.abs(G)/G0) + 1) - def F(U, G, G0): - # G = adjusted(G,G0) - return (G - convolve(G,U)) / np.sqrt((convolve(G,U))**2 + G0**2) - - N=2 - U_kernal = np.array([[U(x, y) for y in range(-(N-1)//2,(N-1)//2 + 1)] for x in range(-(N-1)//2,(N-1)//2 + 1)]) - cond_quant = 3.25 * 1e-5 - filtered_G = np.abs(F(U_kernal, G, G0=10**-7 * cond_quant)) - - if plot_process: - plt.imshow(filtered_G, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) - plt.title("Filtered Transconductance") - plt.colorbar() - plt.show() - - # Apply binary threshold to bring out diamonds better - thresh = threshold_otsu(filtered_G) - binary_image = filtered_G < thresh - - if plot_process: - plt.imshow(binary_image, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) - plt.title("Filtered Transconductance Binary") - plt.colorbar() - plt.show() - - # Erode any artifacts and keep just the diamond shapes - footprint = rectangle(13, 6) - erode = skimage.morphology.erosion(binary_image,footprint) - - footprint = diamond(1) - erode = skimage.morphology.erosion(erode,footprint) - - if plot_process: - plt.imshow(erode, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) - plt.title("Filtered Transconductance Binary Eroded") - plt.show() + plt.title("Hough Transform Lines from Original Data") + plt.show() - # Attempt to find contours - contours = skimage.measure.find_contours(erode, 0.8) + # Now, we detect lines from the gradient - if len(contours) == 0: - return - - # Display the image and plot all contours found - fig, ax = plt.subplots() + plt.figure(figsize = (10, 6)) + plt.imshow( + G, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) - ax.imshow(Zdata, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) - ax.set_title(r'$I_{SD}$') - ax.set_ylabel(r'$V_{SD}$ (V)') - ax.set_xlabel(r'$V_{P}$ (V)') - ax.set_aspect('auto') + lines = transform.probabilistic_hough_line( + G, + threshold=15, + line_length=max(2, int(minLineLength * 0.1)), + line_gap=max(1, int(maxLineGap * 0.02)) + ) + + if not lines: + return [] + + # Now, we filter for lines within a certain angle range + + filtered_lines = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + if minAngleDeg <= angle <= maxAngleDeg: + filtered_lines.append((*p0, *p1)) + + if not filtered_lines: + return [] + + for x1, y1, x2, y2 in filtered_lines: + hx = 0.5 * (x1 + x2) + hy = 0.5 * (y1 + y2) + + # Check if line midpoint is near any selected block peaks + keep_line = False + for tr in perp_traces_for_plot: + idx = np.arange(len(tr["s"])) + if idx is None or len(idx) == 0: + continue + valid_peaks = np.intersect1d(tr["peak_idx"], idx) + if len(valid_peaks) > 0: + mx = np.mean(tr["px"][valid_peaks]) + my = np.mean(tr["py"][valid_peaks]) + if np.hypot(hx - mx, hy - my) < max(perp_length_pixels, 5): + keep_line = True + break + + if keep_line: + v1x = np.interp(x1, x_index_arr, lb_voltages) + v1y = np.interp(y1, y_index_arr, rb_voltages) + v2x = np.interp(x2, x_index_arr, lb_voltages) + v2y = np.interp(y2, y_index_arr, rb_voltages) + plt.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) + + plt.title("Hough Transform Lines from Gradient") + plt.show() - addition_voltages = [] - charging_voltages = [] - results = {} + # Now, we detect lines from the normalized Gradient + plt.figure(figsize = (10, 6)) + plt.imshow( + G_uint, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) - for i, contour in enumerate(contours): - if len(contour) < 350: - continue + lines = transform.probabilistic_hough_line( + G_uint, + threshold=15, + line_length=max(2, int(minLineLength * 0.1)), + line_gap=max(1, int(maxLineGap * 0.02)) + ) - # Convert to proper units for calculations - image_units = [] - for coordinate in contour: - image_units.append([Ydata[int(coordinate[0])], Xdata[int(coordinate[1])]]) - image_units = np.array(image_units) + if not lines: + return [] + + # Now, we filter for lines within a certain angle range + + filtered_lines = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + if minAngleDeg <= angle <= maxAngleDeg: + filtered_lines.append((*p0, *p1)) + + if not filtered_lines: + return [] + + for x1, y1, x2, y2 in filtered_lines: + hx = 0.5 * (x1 + x2) + hy = 0.5 * (y1 + y2) + + # Check if line midpoint is near any selected block peaks + keep_line = False + for tr in perp_traces_for_plot: + idx = np.arange(len(tr["s"])) + if idx is None or len(idx) == 0: + continue + valid_peaks = np.intersect1d(tr["peak_idx"], idx) + if len(valid_peaks) > 0: + mx = np.mean(tr["px"][valid_peaks]) + my = np.mean(tr["py"][valid_peaks]) + if np.hypot(hx - mx, hy - my) < max(perp_length_pixels, 5): + keep_line = True + break + + if keep_line: + v1x = np.interp(x1, x_index_arr, lb_voltages) + v1y = np.interp(y1, y_index_arr, rb_voltages) + v2x = np.interp(x2, x_index_arr, lb_voltages) + v2y = np.interp(y2, y_index_arr, rb_voltages) + plt.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) - Y = image_units[:,0] - X = image_units[:,1] + plt.title("Hough Transform Lines from G Normalized") + plt.show() - Xmax = max(X) - Xmin = min(X) - Ymax = max(Y) - Ymin = min(Y) + # Now, we detect lines from the G_log normalized after thresholding - # Get centroid - centroidX, centroidY = 0.5*(Xmax + Xmin), 0.5 * (Ymax + Ymin) + plt.figure(figsize = (10, 6)) + plt.imshow( + band_passed, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) - dX = Xmax - Xmin - dY = Ymax - Ymin + lines = transform.probabilistic_hough_line( + band_passed, + threshold=15, + line_length=max(2, int(minLineLength * 0.1)), + line_gap=max(1, int(maxLineGap * 0.02)) + ) - divider = 1e-3 - alpha= (Ymax * divider /2) / dX + if not lines: + return [] + + # Now, we filter for lines within a certain angle range + + filtered_lines = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + if minAngleDeg <= angle <= maxAngleDeg: + filtered_lines.append((*p0, *p1)) + + if not filtered_lines: + return [] + + for x1, y1, x2, y2 in filtered_lines: + hx = 0.5 * (x1 + x2) + hy = 0.5 * (y1 + y2) + + # Check if line midpoint is near any selected block peaks + keep_line = False + for tr in perp_traces_for_plot: + idx = np.arange(len(tr["s"])) + if idx is None or len(idx) == 0: + continue + valid_peaks = np.intersect1d(tr["peak_idx"], idx) + if len(valid_peaks) > 0: + mx = np.mean(tr["px"][valid_peaks]) + my = np.mean(tr["py"][valid_peaks]) + if np.hypot(hx - mx, hy - my) < max(perp_length_pixels, 5): + keep_line = True + break + + if keep_line: + v1x = np.interp(x1, x_index_arr, lb_voltages) + v1y = np.interp(y1, y_index_arr, rb_voltages) + v2x = np.interp(x2, x_index_arr, lb_voltages) + v2y = np.interp(y2, y_index_arr, rb_voltages) + plt.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) + + plt.title("Hough Transform Lines from band-passed data") + plt.show() - e = 1.60217663e-19 # C + # Now, we detect lines from the ridges - eps0 = 8.8541878128e-12 # F/m - epsR = 11.7 # Silicon + plt.figure(figsize = (10, 6)) + plt.imshow( + ridge, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) - Vadd = Xmax - Xmin # V - Vc = dY * divider /2 # V - addition_voltages += [Vadd] - charging_voltages += [Vc] - C_P = e / Vadd # F - C_sigma = e / Vc # F - dot_size = C_sigma / (8 * eps0 * epsR) # m - alpha = (dY * divider /2) / dX # eV/V + lines = transform.probabilistic_hough_line( + ridge, + threshold=15, + line_length=max(2, int(minLineLength * 0.1)), + line_gap=max(1, int(maxLineGap * 0.02)) + ) - results[i]= { - 'centroid': (centroidX, centroidY), - 'Vadd': Vadd, - 'Vcharge': Vc, - 'Cp': C_P, - 'CSigma': C_sigma, - 'lever arm': alpha, - 'dot size': dot_size - } + if not lines: + return [] + + # Now, we filter for lines within a certain angle range + + filtered_lines = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + if minAngleDeg <= angle <= maxAngleDeg: + filtered_lines.append((*p0, *p1)) + + if not filtered_lines: + return [] + + for x1, y1, x2, y2 in filtered_lines: + hx = 0.5 * (x1 + x2) + hy = 0.5 * (y1 + y2) + + # Check if line midpoint is near any selected block peaks + keep_line = False + for tr in perp_traces_for_plot: + idx = np.arange(len(tr["s"])) + if idx is None or len(idx) == 0: + continue + valid_peaks = np.intersect1d(tr["peak_idx"], idx) + if len(valid_peaks) > 0: + mx = np.mean(tr["px"][valid_peaks]) + my = np.mean(tr["py"][valid_peaks]) + if np.hypot(hx - mx, hy - my) < max(perp_length_pixels, 5): + keep_line = True + break + + if keep_line: + v1x = np.interp(x1, x_index_arr, lb_voltages) + v1y = np.interp(y1, y_index_arr, rb_voltages) + v2x = np.interp(x2, x_index_arr, lb_voltages) + v2y = np.interp(y2, y_index_arr, rb_voltages) + plt.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) + + plt.title("Hough Transform Lines from Ridges") + plt.show() - ax.plot(image_units[:, 1], image_units[:, 0], linewidth=1, linestyle='-', c='k') - label_text = r'$\alpha$ =' + str(round(alpha,3)) - ax.text(0.98*centroidX, 1.2 * Ymax, label_text, color='k', fontsize=8, verticalalignment='bottom') + # Now, we detect lines from the Normalized Ridges - label_text = r'$V_{add}$ =' + str(round(Vadd*1e3,1)) + 'mV' - ax.text(0.95*centroidX, 1.3 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') + plt.figure(figsize = (10, 6)) + plt.imshow( + ridge_norm, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) - label_text = r'$V_{charge}$ =' + str(round(Vc * 1e3,1)) + 'mV' - ax.text(0.95*centroidX, 1.5 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') + lines = transform.probabilistic_hough_line( + ridge_norm, + threshold=15, + line_length=max(2, int(minLineLength * 0.1)), + line_gap=max(1, int(maxLineGap * 0.02)) + ) - label_text = r'$C_{P}$ =' + str(round((e / Vadd) * 1e18,2)) + 'aF' - ax.text(0.95*centroidX, 1.7 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') + if not lines: + return [] + + # Now, we filter for lines within a certain angle range + + filtered_lines = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + if minAngleDeg <= angle <= maxAngleDeg: + filtered_lines.append((*p0, *p1)) + + if not filtered_lines: + return [] + + for x1, y1, x2, y2 in filtered_lines: + hx = 0.5 * (x1 + x2) + hy = 0.5 * (y1 + y2) + + # Check if line midpoint is near any selected block peaks + keep_line = False + for tr in perp_traces_for_plot: + idx = np.arange(len(tr["s"])) + if idx is None or len(idx) == 0: + continue + valid_peaks = np.intersect1d(tr["peak_idx"], idx) + if len(valid_peaks) > 0: + mx = np.mean(tr["px"][valid_peaks]) + my = np.mean(tr["py"][valid_peaks]) + if np.hypot(hx - mx, hy - my) < max(perp_length_pixels, 5): + keep_line = True + break + + if keep_line: + v1x = np.interp(x1, x_index_arr, lb_voltages) + v1y = np.interp(y1, y_index_arr, rb_voltages) + v2x = np.interp(x2, x_index_arr, lb_voltages) + v2y = np.interp(y2, y_index_arr, rb_voltages) + plt.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) + + plt.title("Hough Transform Lines from Normalized Ridges") + plt.show() + + # Now, we detect lines from the ridges after filtering + + plt.figure(figsize = (10, 6)) + plt.imshow( + ridge_filtered, + extent=[lb_data.min(), lb_data.max(), rb_data.min(), rb_data.max()], + origin='lower', + aspect='auto', + cmap='coolwarm' + ) + + lines = transform.probabilistic_hough_line( + ridge_filtered, + threshold=15, + line_length=max(2, int(minLineLength * 0.1)), + line_gap=max(1, int(maxLineGap * 0.02)) + ) + + if not lines: + return [] + + # Now, we filter for lines within a certain angle range + + filtered_lines = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + if minAngleDeg <= angle <= maxAngleDeg: + filtered_lines.append((*p0, *p1)) + + if not filtered_lines: + return [] + + for x1, y1, x2, y2 in filtered_lines: + hx = 0.5 * (x1 + x2) + hy = 0.5 * (y1 + y2) + + # Check if line midpoint is near any selected block peaks + keep_line = False + for tr in perp_traces_for_plot: + idx = np.arange(len(tr["s"])) + if idx is None or len(idx) == 0: + continue + valid_peaks = np.intersect1d(tr["peak_idx"], idx) + if len(valid_peaks) > 0: + mx = np.mean(tr["px"][valid_peaks]) + my = np.mean(tr["py"][valid_peaks]) + if np.hypot(hx - mx, hy - my) < max(perp_length_pixels, 5): + keep_line = True + break + + if keep_line: + v1x = np.interp(x1, x_index_arr, lb_voltages) + v1y = np.interp(y1, y_index_arr, rb_voltages) + v2x = np.interp(x2, x_index_arr, lb_voltages) + v2y = np.interp(y2, y_index_arr, rb_voltages) + plt.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) + + plt.title("Hough Transform Lines from Filtered Ridges") + plt.show() + + return perp_bias_points, perp_traces_for_plot + +def extract_lever_arms(data: pd.DataFrame, + plot_process: bool = False) -> dict: + + # Load in data and seperate + X_name, Y_name, Z_name = data.columns + Xdata, Ydata = np.unique(data[X_name]), np.unique(data[Y_name]) - label_text = r'$C_{\Sigma}$ =' + str(round((e / Vc) * 1e18,2)) + 'aF' - ax.text(0.95*centroidX, 1.9 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') - ax.scatter([centroidX], [centroidY], marker='*', s=30, c='k') + df_pivoted = data.pivot_table(values=Z_name, index=Y_name, columns=X_name).fillna(0) + Zdata = df_pivoted.to_numpy() - label_text = r'$R_{dot}$ =' + str(round(dot_size * 1e9,2)) + 'nm' - ax.text(0.95*centroidX, 2.1 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') - ax.scatter([centroidX], [centroidY], marker='*', s=30, c='k') + # Calculate conductance where G = dI / dVp + G = np.gradient(Zdata)[1] + if plot_process: + plt.imshow(G, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) + plt.title("Transconductance") + plt.colorbar() plt.show() - return results \ No newline at end of file + + # Apply filter to bring out edges better + def U(x,y): + sigX, sigY = 5,5 + return (1/(2 * np.pi * sigX * sigY)) * np.exp(- 0.5* ((x/sigX)**2 + (y/sigY)**2)) + def adjusted(G,G0): + return np.sign(G) * np.log((np.abs(G)/G0) + 1) + def F(U, G, G0): + # G = adjusted(G,G0) + return (G - convolve(G,U)) / np.sqrt((convolve(G,U))**2 + G0**2) + + N=2 + U_kernal = np.array([[U(x, y) for y in range(-(N-1)//2,(N-1)//2 + 1)] for x in range(-(N-1)//2,(N-1)//2 + 1)]) + cond_quant = 3.25 * 1e-5 + filtered_G = np.abs(F(U_kernal, G, G0=10**-7 * cond_quant)) + + if plot_process: + plt.imshow(filtered_G, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) + plt.title("Filtered Transconductance") + plt.colorbar() + plt.show() + + # Apply binary threshold to bring out diamonds better + thresh = threshold_otsu(filtered_G) + binary_image = filtered_G < thresh + + if plot_process: + plt.imshow(binary_image, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) + plt.title("Filtered Transconductance Binary") + plt.colorbar() + plt.show() + + # Erode any artifacts and keep just the diamond shapes + footprint = rectangle(13, 6) + erode = skimage.morphology.erosion(binary_image,footprint) + + footprint = diamond(1) + erode = skimage.morphology.erosion(erode,footprint) + + if plot_process: + plt.imshow(erode, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) + plt.title("Filtered Transconductance Binary Eroded") + plt.show() + + # Attempt to find contours + contours = skimage.measure.find_contours(erode, 0.8) + + if len(contours) == 0: + return + + # Display the image and plot all contours found + fig, ax = plt.subplots() + + ax.imshow(Zdata, origin='lower', extent=[Xdata.min(), Xdata.max(), Ydata.min() , Ydata.max()], aspect=(Xdata.max() - Xdata.min())/(Ydata.max() - Ydata.min())) + ax.set_title(r'$I_{SD}$') + ax.set_ylabel(r'$V_{SD}$ (V)') + ax.set_xlabel(r'$V_{P}$ (V)') + ax.set_aspect('auto') + + addition_voltages = [] + charging_voltages = [] + results = {} + + + for i, contour in enumerate(contours): + if len(contour) < 350: + continue + + # Convert to proper units for calculations + image_units = [] + for coordinate in contour: + image_units.append([Ydata[int(coordinate[0])], Xdata[int(coordinate[1])]]) + image_units = np.array(image_units) + + Y = image_units[:,0] + X = image_units[:,1] + + Xmax = max(X) + Xmin = min(X) + Ymax = max(Y) + Ymin = min(Y) + + # Get centroid + centroidX, centroidY = 0.5*(Xmax + Xmin), 0.5 * (Ymax + Ymin) + + dX = Xmax - Xmin + dY = Ymax - Ymin + + divider = 1e-3 + alpha= (Ymax * divider /2) / dX + + e = 1.60217663e-19 # C + + eps0 = 8.8541878128e-12 # F/m + epsR = 11.7 # Silicon + + Vadd = Xmax - Xmin # V + Vc = dY * divider /2 # V + addition_voltages += [Vadd] + charging_voltages += [Vc] + C_P = e / Vadd # F + C_sigma = e / Vc # F + dot_size = C_sigma / (8 * eps0 * epsR) # m + alpha = (dY * divider /2) / dX # eV/V + + results[i]= { + 'centroid': (centroidX, centroidY), + 'Vadd': Vadd, + 'Vcharge': Vc, + 'Cp': C_P, + 'CSigma': C_sigma, + 'lever arm': alpha, + 'dot size': dot_size + } + + ax.plot(image_units[:, 1], image_units[:, 0], linewidth=1, linestyle='-', c='k') + label_text = r'$\alpha$ =' + str(round(alpha,3)) + ax.text(0.98*centroidX, 1.2 * Ymax, label_text, color='k', fontsize=8, verticalalignment='bottom') + + label_text = r'$V_{add}$ =' + str(round(Vadd*1e3,1)) + 'mV' + ax.text(0.95*centroidX, 1.3 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') + + label_text = r'$V_{charge}$ =' + str(round(Vc * 1e3,1)) + 'mV' + ax.text(0.95*centroidX, 1.5 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') + + label_text = r'$C_{P}$ =' + str(round((e / Vadd) * 1e18,2)) + 'aF' + ax.text(0.95*centroidX, 1.7 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') + + label_text = r'$C_{\Sigma}$ =' + str(round((e / Vc) * 1e18,2)) + 'aF' + ax.text(0.95*centroidX, 1.9 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') + ax.scatter([centroidX], [centroidY], marker='*', s=30, c='k') + + label_text = r'$R_{dot}$ =' + str(round(dot_size * 1e9,2)) + 'nm' + ax.text(0.95*centroidX, 2.1 * Ymin, label_text, color='k', fontsize=8, verticalalignment='bottom') + ax.scatter([centroidX], [centroidY], marker='*', s=30, c='k') + + plt.show() + return results + diff --git a/src/experiment_base.py b/src/experiment_base.py index 3880bda..54c6b71 100644 --- a/src/experiment_base.py +++ b/src/experiment_base.py @@ -2,28 +2,8 @@ File: experiment_base.py Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) -Defines SweepLayer, Sweep, and do_sweep_job for use with ExperimentThread. +Defines SweepLayer objects and the Sweep class to handle all the running of all sweeps used in the Autotuner. -All hardware I/O is routed through the instrument_handler using set_parameter and -get_parameter — the sweep never touches QCoDeS instruments directly. - -A SweepLayer describes one axis: which instrument parameter to drive and over which -setpoints. An Sweep composes layers outermost → innermost, and calls a -user-supplied measurement callback at every innermost point. - -do_sweep_job matches the ExperimentThread calling convention (f(*args, abort_event)) -and is registered like: - - thread.add_job(do_sweep_job, (sweep, instr_handler), priority=1) - -Measurement callback signature --------------------------------- - def my_measure(instr_handler: instrument_handler, - setpoints: tuple) -> Any: - return instr_handler.get_parameter("vna", "S21") - -The return value is stored in AbstractSweep.results as: - {'setpoints': (v0, v1, ...), 'data': } ''' # Imports @@ -91,11 +71,64 @@ def _open_csv(self): header = [f"setpoint_{i}" for i in range(len(self.layers))] + ["data"] self._csv_writer.writerow(header) - def _close_csv(self): if self._csv_file is not None: self._csv_file.close() + def set_voltage_configuration(self, instr_handler, abort_event): + + try: + self.set_voltage_layer( + 0, + instr_handler, + abort_event, + current_setpoints=[] + ) + + finally: + print("Voltage Configuration Set!") + + def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): + + if idx == len(self.layers): + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + + return + + layer = self.layers[idx] + + values = np.linspace(layer.start, layer.end, layer.num_points) + + for val in values: + + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + + logger.info(f"[SWEEP] Step: setting values {val}") + + # Set parameter + instr_handler.set_parameter( + layer.instrument, + {layer.parameter: float(val)}, + wait=True + ) + + # Wait time between points (interruptible) + t0 = time.monotonic() + while time.monotonic() - t0 < layer.measurement_time: + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + time.sleep(0.001) + + # recurse + self.set_voltage_layer( + idx + 1, + instr_handler, + abort_event, + current_setpoints + [float(val)] + ) + def run(self, instr_handler, abort_event): try: @@ -154,7 +187,7 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): while time.monotonic() - t0 < layer.measurement_time: if abort_event.is_set(): raise RuntimeError("Sweep aborted") - time.sleep(0.01) + time.sleep(0.001) logger.info("[SWEEP] Measuring...") diff --git a/src/gui.py b/src/gui.py index 517a5b8..3fe516e 100644 --- a/src/gui.py +++ b/src/gui.py @@ -170,6 +170,11 @@ def root_page(self): 'Run Test Sweep', on_click=self.run_test_sweep ) + + ui.button( + 'Run Test Sweep 2', + on_click=self.run_test_sweep_2 + ) self.debug_status = ui.label('Idle') @@ -197,10 +202,43 @@ def run_test_sweep(self): sweep = Sweep( layers=[ - SweepLayer('spi_rack.module1.dac0.voltage', 0.0, 0.2, 100, 0.01), - SweepLayer('spi_rack.module1.dac1.voltage', 0.0, 0.2, 100, 0.01) + SweepLayer('spi_rack.module1.dac0.voltage', 0.0, 0.2, 100, 0.1), + ], + measure=lambda ih, sp: ih.read_buffer('agilent_left.volt','agilent_right.volt') + ) + + future = self.experiment_handler.do_sweep( + sweep=sweep, + instrument_handler=self.instrument_handler, + wait=False + ) + + def check_result(): + try: + result = future.result(timeout=0) + + except TimeoutError: + ui.timer(0.1, check_result, once=True) + + except Exception as e: + self.debug_status.set_text(f"Error: {e}") + + else: + self.debug_status.set_text(f"Sweep complete: {len(result)} points") + + check_result() + + def run_test_sweep_2(self): + + self.debug_status.set_text("Running sweep...") + self.logger.info("Sweep job started") + + sweep = Sweep( + layers=[ + SweepLayer('spi_rack.module1.dac0.voltage', 0.0, 0.2, 20, 0.01), + SweepLayer('spi_rack.module1.dac1.voltage', 0.0, 0.2, 20, 0.01) ], - measure=lambda ih, sp: ih.read_buffer('agilent_right.volt') + measure=lambda ih, sp: ih.read_buffer('agilent_left.volt','agilent_right.volt') ) future = self.experiment_handler.do_sweep( From e42f2dae7d1504e2d1d867cca2f5d01d2260ef49 Mon Sep 17 00:00:00 2001 From: VanOschB <118694763+VanOschB@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:25:13 -0400 Subject: [PATCH 19/36] Sweep Logic Updates (Commit 3) Updated the Sweep methods to allow for multiple instrument parameters to be swept in the same sweep layer. --- src/experiment_base.py | 123 ++++++++++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 45 deletions(-) diff --git a/src/experiment_base.py b/src/experiment_base.py index 54c6b71..54355e6 100644 --- a/src/experiment_base.py +++ b/src/experiment_base.py @@ -28,34 +28,35 @@ logger = TunerLog('Exp. Base') @dataclass -class SweepLayer: - param: str +class SweepParam: + parameter: str start: float end: float + +@dataclass +class SweepLayer: + targets: list[SweepParam] num_points: int measurement_time: float def __post_init__(self): - if '.' not in self.param: - raise ValueError("param must be 'instrument.parameter'") if self.num_points <= 0: raise ValueError("num_points must be > 0") - @property - def instrument(self): - return self.param.split('.', 1)[0] - - @property - def parameter(self): - return self.param.split('.', 1)[1] - class Sweep: def __init__(self, layers, measure): + self.layers = layers self.measure = measure self.results = [] + self.all_params = [ + p.parameter + for layer in self.layers + for p in layer.targets + ] + self.filename = f"sweep_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" desktop = os.path.join(os.path.expanduser("~"), "Desktop") @@ -68,7 +69,7 @@ def _open_csv(self): self._csv_file = open(self.csv_path, "w", newline="") self._csv_writer = csv.writer(self._csv_file) - header = [f"setpoint_{i}" for i in range(len(self.layers))] + ["data"] + header = self.all_params + ["data"] self._csv_writer.writerow(header) def _close_csv(self): @@ -82,7 +83,7 @@ def set_voltage_configuration(self, instr_handler, abort_event): 0, instr_handler, abort_event, - current_setpoints=[] + current_setpoints={} ) finally: @@ -93,40 +94,55 @@ def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): if idx == len(self.layers): if abort_event.is_set(): raise RuntimeError("Sweep aborted") - return layer = self.layers[idx] - values = np.linspace(layer.start, layer.end, layer.num_points) + values_per_param = [ + np.linspace(p.start, p.end, layer.num_points) + for p in layer.targets + ] - for val in values: + for i in range(layer.num_points): if abort_event.is_set(): raise RuntimeError("Sweep aborted") - logger.info(f"[SWEEP] Step: setting values {val}") + step_values = {} - # Set parameter - instr_handler.set_parameter( - layer.instrument, - {layer.parameter: float(val)}, - wait=True - ) + for p, values in zip(layer.targets, values_per_param): + val = float(values[i]) + + instr, param = p.parameter.split('.', 1) + + logger.info(f"[SWEEP] {p.parameter} -> {val}") + + instr_handler.set_parameter( + instr, + {param: val}, + wait=True + ) + + step_values[p.parameter] = val + + # Wait - # Wait time between points (interruptible) t0 = time.monotonic() while time.monotonic() - t0 < layer.measurement_time: if abort_event.is_set(): raise RuntimeError("Sweep aborted") time.sleep(0.001) - # recurse + new_setpoints = current_setpoints.copy() + new_setpoints.update(step_values) + + # Recurse + self.set_voltage_layer( idx + 1, instr_handler, abort_event, - current_setpoints + [float(val)] + new_setpoints ) def run(self, instr_handler, abort_event): @@ -138,7 +154,7 @@ def run(self, instr_handler, abort_event): 0, instr_handler, abort_event, - current_setpoints=[] + current_setpoints={} ) finally: @@ -150,15 +166,16 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): if abort_event.is_set(): raise RuntimeError("Sweep aborted") - data = self.measure(instr_handler, tuple(current_setpoints)) - + data = self.measure(instr_handler, current_setpoints.copy()) # ✅ fixed + self.results.append({ - "setpoints": tuple(current_setpoints), + "setpoints": current_setpoints.copy(), "data": data }) - # Write to CSV - row = list(current_setpoints) + [data] + # CSV write + + row = [current_setpoints.get(p, None) for p in self.all_params] + [data] self._csv_writer.writerow(row) self._csv_file.flush() @@ -166,23 +183,35 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): layer = self.layers[idx] - values = np.linspace(layer.start, layer.end, layer.num_points) + values_per_param = [ + np.linspace(p.start, p.end, layer.num_points) + for p in layer.targets + ] - for val in values: + for i in range(layer.num_points): if abort_event.is_set(): raise RuntimeError("Sweep aborted") - logger.info(f"[SWEEP] Step: setting values {val}") + step_values = {} - # Set parameter - instr_handler.set_parameter( - layer.instrument, - {layer.parameter: float(val)}, - wait=True - ) + for p, values in zip(layer.targets, values_per_param): + val = float(values[i]) + + instr, param = p.parameter.split('.', 1) + + logger.info(f"[SWEEP] {p.parameter} -> {val}") # ✅ fixed + + instr_handler.set_parameter( + instr, + {param: val}, + wait=True + ) + + step_values[p.parameter] = val + + # Wait - # Measurement wait (interruptible) t0 = time.monotonic() while time.monotonic() - t0 < layer.measurement_time: if abort_event.is_set(): @@ -191,10 +220,14 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): logger.info("[SWEEP] Measuring...") - # recurse + new_setpoints = current_setpoints.copy() + new_setpoints.update(step_values) + + # Recurse + self._run_layer( idx + 1, instr_handler, abort_event, - current_setpoints + [float(val)] + new_setpoints ) \ No newline at end of file From 1027053136a06ff85b5093dfcf290e2910aed26f Mon Sep 17 00:00:00 2001 From: Ben Van Osch Date: Sat, 2 May 2026 14:38:57 -0400 Subject: [PATCH 20/36] Resetting During Sweeps Added a helper function to allow for multi-layer sweeps to reset smoothly in-between each layer completion. Also, updated the format of the saved CSV file, though currently the header keys are hard-coded. This will require an additional edit to be able to dynamically add readout instrument names later. run_sweep 1 2 and 3 buttons are used to debug each of these features. The next steps are to test a sequence of sweeps akin to the autotuning code in totality. Then, connections must be made between each subsequent sweep, with additional data analysis taking place. --- src/experiment_base.py | 182 ++++++++++++++++++++++++++++++++------ src/experiment_handler.py | 31 ++++++- src/gui.py | 114 ++++++++++++++++++++---- 3 files changed, 284 insertions(+), 43 deletions(-) diff --git a/src/experiment_base.py b/src/experiment_base.py index 54355e6..94d1ae3 100644 --- a/src/experiment_base.py +++ b/src/experiment_base.py @@ -66,24 +66,32 @@ def __init__(self, layers, measure): self._csv_writer = None def _open_csv(self): + + keys = [ + 'agilent_left.volt', + 'agilent_right.volt' + ] + ap = list(self.all_params) + + self._header = ap + keys + self._csv_file = open(self.csv_path, "w", newline="") + self._csv_writer = csv.writer(self._csv_file) - - header = self.all_params + ["data"] - self._csv_writer.writerow(header) + self._csv_writer.writerow(self._header) def _close_csv(self): if self._csv_file is not None: self._csv_file.close() - def set_voltage_configuration(self, instr_handler, abort_event): + def set_voltage_configuration(self, instr_handler, abort_event, current_setpoints = {}): try: self.set_voltage_layer( 0, instr_handler, abort_event, - current_setpoints={} + current_setpoints=current_setpoints ) finally: @@ -91,6 +99,28 @@ def set_voltage_configuration(self, instr_handler, abort_event): def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): + """ + A method that sets a particular voltage configuration without measurement. The intended use of this method + is to set voltage configurations in between experiments, as well as allow for smooth resetting of voltages + once a layer has been completely swept. + + Parameters + ---------- + name: idx + The layer index for the sweep. In set_voltage_configuration, this is always set to 0 initially. + + instr_handler: + The instrument_handler instance that instantiates when the gui is run. + + abort_event: + The abort event that can be dynamically updated to abort any experiment job if needed. + + current_setpoints: + The current values set on the instrument. Defaults to empty. + + """ + + if idx == len(self.layers): if abort_event.is_set(): raise RuntimeError("Sweep aborted") @@ -125,13 +155,13 @@ def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): step_values[p.parameter] = val - # Wait + # Wait - t0 = time.monotonic() - while time.monotonic() - t0 < layer.measurement_time: - if abort_event.is_set(): - raise RuntimeError("Sweep aborted") - time.sleep(0.001) + t0 = time.monotonic() + while time.monotonic() - t0 < layer.measurement_time: + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + time.sleep(0.001) new_setpoints = current_setpoints.copy() new_setpoints.update(step_values) @@ -145,7 +175,7 @@ def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): new_setpoints ) - def run(self, instr_handler, abort_event): + def run(self, instr_handler, abort_event, current_setpoints = {}): try: self._open_csv() @@ -154,7 +184,7 @@ def run(self, instr_handler, abort_event): 0, instr_handler, abort_event, - current_setpoints={} + current_setpoints=current_setpoints ) finally: @@ -162,20 +192,30 @@ def run(self, instr_handler, abort_event): def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): + layer_start_setpoints = current_setpoints.copy() + if idx == len(self.layers): if abort_event.is_set(): raise RuntimeError("Sweep aborted") - - data = self.measure(instr_handler, current_setpoints.copy()) # ✅ fixed + data, keys = self.measure(instr_handler, current_setpoints.copy()) + self.results.append({ "setpoints": current_setpoints.copy(), "data": data }) - # CSV write - - row = [current_setpoints.get(p, None) for p in self.all_params] + [data] + row = [current_setpoints.get(p, None) for p in self.all_params] + + if isinstance(data, dict): + for k in keys: + val = data.get(k, None) + row.append(float(val) if val is not None else "") + else: + try: + row.append(float(data)) + except (TypeError, ValueError): + row.append("") self._csv_writer.writerow(row) self._csv_file.flush() @@ -200,7 +240,7 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): instr, param = p.parameter.split('.', 1) - logger.info(f"[SWEEP] {p.parameter} -> {val}") # ✅ fixed + logger.info(f"[SWEEP] {p.parameter} -> {val}") instr_handler.set_parameter( instr, @@ -210,19 +250,21 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): step_values[p.parameter] = val - # Wait + # Wait - t0 = time.monotonic() - while time.monotonic() - t0 < layer.measurement_time: - if abort_event.is_set(): - raise RuntimeError("Sweep aborted") - time.sleep(0.001) + t0 = time.perf_counter() - logger.info("[SWEEP] Measuring...") + while time.perf_counter() - t0 < layer.measurement_time: + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + time.sleep(0.001) new_setpoints = current_setpoints.copy() new_setpoints.update(step_values) + print(f"layer_start_setpoints: {layer_start_setpoints}") + print(f"new_setpoints: {new_setpoints}") + # Recurse self._run_layer( @@ -230,4 +272,90 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): instr_handler, abort_event, new_setpoints - ) \ No newline at end of file + ) + + layer_start_setpoints = current_setpoints.copy() + + if idx < len(self.layers) - 1 and i < layer.num_points - 1: + + reset_layers = self._build_reset_layers( + idx, + p.end, + p.start, + num_points=10 + ) + + print(f"reset_layers: {reset_layers}") + + # Save original layers + original_layers = self.layers + + print(f"original_layers: {original_layers}") + + try: + # Swap in reset layers + self.layers = reset_layers + + # Call your existing function + self.set_voltage_layer( + 0, + instr_handler, + abort_event, + new_setpoints + ) + + finally: + # Restore original layers + self.layers = original_layers + + def _build_reset_layers(self, idx, start_setpoints, end_setpoints, num_points=100): + + """ + Build a temporary list of layers that sweep from end_setpoints back to start_setpoints + using the same parameter structure as self.layers[idx:]. + """ + + print(f"start_setpoints inside: {start_setpoints}") + print(f"end_setpoints inside: {end_setpoints}") + + reset_layers = [] + + for layer in self.layers[idx + 1:]: + + new_targets = [] + + for p in layer.targets: + param = p.parameter + + print(f"param: {param}") + + v_start = start_setpoints + v_end = end_setpoints + + print(f"v_start: {v_start}") + print(f"v_end: {v_end}") + + if v_start is None or v_end is None: + continue + + # Create a shallow copy-like object with reversed sweep + new_p = type(p)( + parameter=p.parameter, + start=v_start, + end=v_end + ) + + print(f"new_p: {new_p}") + + new_targets.append(new_p) + + # Recreate layer with higher resolution + new_layer = type(layer)( + targets=new_targets, + num_points=num_points, + measurement_time=layer.measurement_time + ) + + reset_layers.append(new_layer) + + return reset_layers \ No newline at end of file diff --git a/src/experiment_handler.py b/src/experiment_handler.py index fe6c216..e72f83f 100644 --- a/src/experiment_handler.py +++ b/src/experiment_handler.py @@ -156,6 +156,7 @@ def __init__(self, experiment_thread): def do_sweep(self, sweep, instrument_handler, + current_setpoints = {}, wait: bool = True, timeout: float = 60, filename: str = "sweep.csv"): @@ -163,7 +164,35 @@ def do_sweep(self, future = TunerFuture() def sweep_fn(abort_event): - result = sweep.run(instrument_handler, abort_event) + result = sweep.run(instrument_handler, abort_event, current_setpoints) + + future.set_result(result) + return result + + self.experiment_thread.add_job( + sweep_fn, + args=(), + wait=False + ) + + if wait: + return future.result(timeout) + else: + return future + + def set_voltage_configuration(self, + sweep, + instrument_handler, + current_setpoints = {}, + wait: bool = True, + timeout: float = 60, + filename: str = "sweep.csv"): + + future = TunerFuture() + + def sweep_fn(abort_event): + result = sweep.set_voltage_configuration(instrument_handler, abort_event, current_setpoints) + future.set_result(result) return result diff --git a/src/gui.py b/src/gui.py index 3fe516e..3924106 100644 --- a/src/gui.py +++ b/src/gui.py @@ -25,7 +25,7 @@ import random import os, sys from tunerlog import TunerLog -from experiment_base import SweepLayer, Sweep +from experiment_base import SweepParam, SweepLayer, Sweep class RandomDummy(DummyInstrument): ''' @@ -74,7 +74,7 @@ def __init__(self): def init_agilent(instrument: Instrument, *args): instrument.NPLC(1.0) - instrument.range_auto('off') + instrument.range_auto('on') def init_spi_rack(instrument: Instrument, *args): @@ -176,6 +176,11 @@ def root_page(self): on_click=self.run_test_sweep_2 ) + ui.button( + 'Run Test Sweep 3', + on_click=self.run_test_sweep_3 + ) + self.debug_status = ui.label('Idle') with splitter1.after: @@ -192,19 +197,32 @@ def root_page(self): self.logger.add_ui_handler(self.ui_log) self.logger.info("Added NiceGUI UI handler to logger.") - ui.timer(0.25, self.update_liveplot) + ui.timer(0.025, self.update_liveplot) ui.timer(0.5, self.watchdog_timer) def run_test_sweep(self): self.debug_status.set_text("Running sweep...") - self.logger.info("Sweep job started") + self.logger.info("Sweep job queued") sweep = Sweep( layers=[ - SweepLayer('spi_rack.module1.dac0.voltage', 0.0, 0.2, 100, 0.1), + SweepLayer( + targets=[ + SweepParam('spi_rack.module1.dac0.voltage', 0.1, 0.0), + SweepParam('spi_rack.module1.dac1.voltage', 0.1, 0.0) + ], + num_points=20, + measurement_time=0.1 + ) ], - measure=lambda ih, sp: ih.read_buffer('agilent_left.volt','agilent_right.volt') + measure=lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) ) future = self.experiment_handler.do_sweep( @@ -224,21 +242,39 @@ def check_result(): self.debug_status.set_text(f"Error: {e}") else: - self.debug_status.set_text(f"Sweep complete: {len(result)} points") + self.debug_status.set_text(f"Sweep complete!") check_result() def run_test_sweep_2(self): self.debug_status.set_text("Running sweep...") - self.logger.info("Sweep job started") + self.logger.info("Sweep job queued") sweep = Sweep( layers=[ - SweepLayer('spi_rack.module1.dac0.voltage', 0.0, 0.2, 20, 0.01), - SweepLayer('spi_rack.module1.dac1.voltage', 0.0, 0.2, 20, 0.01) + SweepLayer( + targets=[ + SweepParam('spi_rack.module1.dac0.voltage', 0.0, 0.3) + ], + num_points=20, + measurement_time=0.05 + ), + SweepLayer( + targets=[ + SweepParam('spi_rack.module1.dac1.voltage', 0.0, 0.3) + ], + num_points=20, + measurement_time=0.05 + ) ], - measure=lambda ih, sp: ih.read_buffer('agilent_left.volt','agilent_right.volt') + measure=lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) ) future = self.experiment_handler.do_sweep( @@ -258,7 +294,56 @@ def check_result(): self.debug_status.set_text(f"Error: {e}") else: - self.debug_status.set_text(f"Sweep complete: {len(result)} points") + self.debug_status.set_text(f"Sweep complete!") + + check_result() + + def run_test_sweep_3(self): + + self.debug_status.set_text("Running sweep...") + self.logger.info("Sweep job queued") + + sweep = Sweep( + layers=[ + SweepLayer( + targets=[ + SweepParam('spi_rack.module1.dac0.voltage', 0.0, 0.5) + ], + num_points=20, + measurement_time=0.05 + ), + SweepLayer( + targets=[ + SweepParam('spi_rack.module1.dac1.voltage', 0.0, 0.3) + ], + num_points=20, + measurement_time=0.05 + ) + ], + measure=lambda ih, sp: ih.read_buffer( + ['agilent_left.volt', + 'agilent_right.volt' + ]) + ) + + future = self.experiment_handler.set_voltage_configuration( + sweep=sweep, + instrument_handler=self.instrument_handler, + wait=False + ) + + def check_result(): + try: + result = future.result(timeout=0) + + except TimeoutError: + ui.timer(0.1, check_result, once=True) + + except Exception as e: + self.debug_status.set_text(f"Error: {e}") + + else: + self.debug_status.set_text(f"Sweep complete!") check_result() @@ -328,7 +413,7 @@ def live_plot_window(self): xs = np.linspace(-1, 1) self.lines = self.ax.plot(xs, np.sin(xs)) self.ax.set_xlabel('time (s)', fontsize = 16) - self.ax.set_ylabel('Current (A)', fontsize = 16) + self.ax.set_ylabel('Signal (V)', fontsize = 16) self.ax.tick_params(labelsize = 12) fig.tight_layout() self.liveplot.update() @@ -364,11 +449,10 @@ def update_liveplot(self): self.lines[j].set_ydata(data) self.lines[j].set_xdata(times_offset) self.ax.set_xlim(min(times_offset), max(times_offset)) - self.ax.set_ylim(-0.5, 1.5) + self.ax.set_ylim(-0.5, 5.0) self.ax.legend(self.lines, keys, ) self.liveplot.update() - #ui.update() def experiment_progress_bar(self): From af54bf27efbc3a86c042f919acaff5feb094612b Mon Sep 17 00:00:00 2001 From: Ben Van Osch Date: Wed, 6 May 2026 10:48:33 -0400 Subject: [PATCH 21/36] Sweep Reset Bug Fixed a bug for set_voltage_layer that would cause the reset layer to just play the entire sweep backwards. --- src/experiment_base.py | 54 +++++++++++++----------------------------- src/gui.py | 9 ++++++- 2 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/experiment_base.py b/src/experiment_base.py index 94d1ae3..9a8a3a4 100644 --- a/src/experiment_base.py +++ b/src/experiment_base.py @@ -102,7 +102,7 @@ def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): """ A method that sets a particular voltage configuration without measurement. The intended use of this method is to set voltage configurations in between experiments, as well as allow for smooth resetting of voltages - once a layer has been completely swept. + once a layer has been completely swept. THIS METHOD DOES NOT RECURSE. Parameters ---------- @@ -120,6 +120,8 @@ def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): """ + if idx != 0: + raise ValueError("Setting a voltage layer should only have one layer!") if idx == len(self.layers): if abort_event.is_set(): @@ -166,15 +168,6 @@ def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): new_setpoints = current_setpoints.copy() new_setpoints.update(step_values) - # Recurse - - self.set_voltage_layer( - idx + 1, - instr_handler, - abort_event, - new_setpoints - ) - def run(self, instr_handler, abort_event, current_setpoints = {}): try: @@ -192,8 +185,6 @@ def run(self, instr_handler, abort_event, current_setpoints = {}): def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): - layer_start_setpoints = current_setpoints.copy() - if idx == len(self.layers): if abort_event.is_set(): raise RuntimeError("Sweep aborted") @@ -262,9 +253,6 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): new_setpoints = current_setpoints.copy() new_setpoints.update(step_values) - print(f"layer_start_setpoints: {layer_start_setpoints}") - print(f"new_setpoints: {new_setpoints}") - # Recurse self._run_layer( @@ -274,18 +262,16 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): new_setpoints ) - layer_start_setpoints = current_setpoints.copy() - if idx < len(self.layers) - 1 and i < layer.num_points - 1: - reset_layers = self._build_reset_layers( + reset_layer = self._build_reset_layers( idx, p.end, p.start, - num_points=10 + num_points=50 ) - print(f"reset_layers: {reset_layers}") + print(f"reset_layers: {reset_layer}") # Save original layers original_layers = self.layers @@ -294,7 +280,7 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): try: # Swap in reset layers - self.layers = reset_layers + self.layers = reset_layer # Call your existing function self.set_voltage_layer( @@ -315,15 +301,12 @@ def _build_reset_layers(self, idx, start_setpoints, end_setpoints, num_points=10 using the same parameter structure as self.layers[idx:]. """ - print(f"start_setpoints inside: {start_setpoints}") - print(f"end_setpoints inside: {end_setpoints}") + reset_layer = [] - reset_layers = [] + new_targets = [] for layer in self.layers[idx + 1:]: - new_targets = [] - for p in layer.targets: param = p.parameter @@ -332,9 +315,6 @@ def _build_reset_layers(self, idx, start_setpoints, end_setpoints, num_points=10 v_start = start_setpoints v_end = end_setpoints - print(f"v_start: {v_start}") - print(f"v_end: {v_end}") - if v_start is None or v_end is None: continue @@ -349,13 +329,13 @@ def _build_reset_layers(self, idx, start_setpoints, end_setpoints, num_points=10 new_targets.append(new_p) - # Recreate layer with higher resolution - new_layer = type(layer)( - targets=new_targets, - num_points=num_points, - measurement_time=layer.measurement_time - ) + # Recreate layer with higher resolution + new_layer = type(layer)( + targets=new_targets, + num_points=num_points, + measurement_time=layer.measurement_time + ) - reset_layers.append(new_layer) + reset_layer.append(new_layer) - return reset_layers \ No newline at end of file + return reset_layer \ No newline at end of file diff --git a/src/gui.py b/src/gui.py index 3924106..999a04c 100644 --- a/src/gui.py +++ b/src/gui.py @@ -255,7 +255,7 @@ def run_test_sweep_2(self): layers=[ SweepLayer( targets=[ - SweepParam('spi_rack.module1.dac0.voltage', 0.0, 0.3) + SweepParam('spi_rack.module1.dac2.voltage', 0.0, 0.3) ], num_points=20, measurement_time=0.05 @@ -266,6 +266,13 @@ def run_test_sweep_2(self): ], num_points=20, measurement_time=0.05 + ), + SweepLayer( + targets=[ + SweepParam('spi_rack.module1.dac0.voltage', 0.0, 0.3) + ], + num_points=20, + measurement_time=0.05 ) ], measure=lambda ih, sp: ( From d8e99a01745bd61a69a05d335aa6328369eb714b Mon Sep 17 00:00:00 2001 From: VanOschB <118694763+VanOschB@users.noreply.github.com> Date: Sun, 17 May 2026 18:10:52 -0400 Subject: [PATCH 22/36] Autotuning Protocol Initial Testing Replaced write control with experimental_base. Added outline of functions to autotuning_protocol.py. Fleshed out the writing capabilities of the Bootstrapping class, which should now be ready to debug once buttons are added to the gui to test each function. Also added a parent Protocol class to store config file data for the rest of the stages. --- configs/Intel_Config.yaml | 154 ++---- src/autotuning_protocol.py | 680 ++++++++++++++++++++++- src/experiment_base.py | 13 +- src/experiment_handler.py | 3 +- src/gui.py | 19 +- src/write_control.py | 1069 ------------------------------------ 6 files changed, 729 insertions(+), 1209 deletions(-) delete mode 100644 src/write_control.py diff --git a/configs/Intel_Config.yaml b/configs/Intel_Config.yaml index 0253152..c880e7c 100644 --- a/configs/Intel_Config.yaml +++ b/configs/Intel_Config.yaml @@ -1,144 +1,102 @@ device: characteristics: - name: 3D1S_4 + name: 3D1S_w151_1 charge_carrier: e operation_mode: acc constraints: - abs_max_current: 10e-9 - abs_max_gate_differential: 2 - abs_max_gate_voltage: 2.0 + abs_max_current_triple_dot: 20e-9 + abs_max_current_SET_dot: 10e-9 + abs_max_gate_differential: 1.5 + abs_max_gate_voltage: 1.5 + abs_max_ohmic_bias: 1e-3 gates: S0: label: Left Dot Ohmic - type: Ohmic - channel: 1 - step: 0.01 - unit: V - S2: + type: Dot Ohmic + channel: module1.dac10 + S3: label: Right Sensor Ohmic - type: Ohmic - channel: 2 - step: 0.01 - unit: V + type: Sensor Ohmic + channel: module1.dac5 SG1: label: Horizontal Screening Gate - type: Screening - channel: 3 - step: 0.01 - unit: V + type: Central Screening + channel: module2.dac8 CG0a: label: Dot Screening Gate - type: Screening - channel: 4 - step: 0.01 - unit: V + type: Dot Screening + channel: module2.dac7 CG0b: label: Sensor Screening Gate - type: Screening - channel: 5 - step: 0.01 - unit: V + type: Sensor Screening + channel: module1.dac4 AC0: label: Left Dot Accumulation Gate - type: Accumulation - channel: 6 - step: 0.01 - unit: V + type: Dot Accumulation + channel: module2.dac5 AC1: label: Right Dot Accumulation Gate - type: Accumulation - channel: 7 - step: 0.01 - unit: V + type: Dot Accumulation + channel: module1.dac2 AC2: label: Right Sensor Accumulation Gate - type: Accumulation - channel: 8 - step: 0.01 - unit: V - AC3: - label: Left Sensor Accumulation Gate - type: Accumulation - channel: 9 - step: 0.01 - unit: V - F1: - label: Flanking Gates - type: Accumulation - channel: 10 - step: 0.01 - unit: V + type: Sensor Accumulation + channel: module1.dac3 + F1 + AC3: + label: Left Sensor Accumulation Gate and Flanking Gates + type: Sensor Accumulation + channel: module2.dac4 B0: label: Left Barrier Gate - type: Barrier - channel: 11 - step: 0.01 - unit: V + type: Dot Barrier + channel: module2.dac10 B1: label: Dot 1-2 Barrier Gate - type: Barrier - channel: 12 - step: 0.01 - unit: V + type: Dot Barrier + channel: module2.dac6 B2: label: Dot 2-3 Barrier Gate - type: Barrier - channel: 13 - step: 0.01 - unit: V + type: Dot Barrier + channel: module1.dac6 B3: label: Right Barrier Gate - type: Barrier - channel: 14 - step: 0.01 - unit: V + type: Dot Barrier + channel: module2.dac11 B20: label: Left Sensor Barrier Gate - type: Barrier - channel: 15 - step: 0.01 - unit: V + type: Sensor Barrier + channel: module1.dac9 B21: label: Right Sensor Barrier Gate - type: Barrier - channel: 16 - step: 0.01 - unit: V + type: Sensor Barrier + channel: module1.dac8 P0: label: Left Dot Plunger - type: Plunger - channel: 17 - step: 0.01 - unit: V + type: Dot Plunger + channel: module2.dac2 P1: label: Middle Dot Plunger - type: Plunger - channel: 18 - step: 0.01 - unit: V + type: Dot Plunger + channel: module2.dac1 P2: label: Right Dot Plunger - type: Plunger - channel: 19 - step: 0.01 - unit: V + type: Dot Plunger + channel: module1.dac1 P20: label: Sensor Plunger - type: Plunger - channel: 20 - step: 0.01 - unit: V + type: Sensor Plunger + channel: module2.dac3 setup: - voltage_sources: - voltage_source_1: SIM928 - voltage_source_2: keithley2000 - multimeter: agilent - preamp_bias: 0.0 - preamp_sensitivity: 1.0e-8 - voltage_divider: 1.0e-3 - voltage_resolution: 1.0e-3 - + triple_dot_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + SET_dot_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + voltage_dividers: + voltage_divider_triple_dot: 1.0e-3 + voltage_divider_SET_dot: 1.0e-3 diff --git a/src/autotuning_protocol.py b/src/autotuning_protocol.py index 81067c1..482fa76 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -21,58 +21,700 @@ from qcodes.parameters import ParameterBase import numpy.typing as npt -import skimage from skimage.transform import probabilistic_hough_line from skimage.feature import canny from skimage.filters import threshold_otsu from skimage.morphology import diamond, rectangle # noqa -import logging -from colorlog import ColoredFormatter +from main import gui +from experiment_base import SweepParam, SweepLayer, Sweep + import sys from nicegui import ui -import threading +from tunerlog import TunerLog -class Bootstrapping: +logger = TunerLog('Autotuner') - def ground_device(): - pass +class Protocol: - def turn_on(): - pass + def __init__(self, device_config): + + ''' + Initializes the protocol. Reads the device configuration file provided and creates a path from gate name to dac. - def pinch_off(): - pass + + ''' + + # First, we load in the config file + + logger.info("Loading Device Config file...") + + self._load_config_file(device_config) + + # Now, we create a dictionary to house a map between gate names and dacs + + self.gates_to_dacs = {} + + for i in self.device_gates: + + self.gates_to_dacs[i] = self.device_gates[i]['channel'] + + def _load_config_file(self, device_config): + + # Read the tuner config information + + self.config = yaml.safe_load(Path(device_config).read_text()) + + # Read the config information + + self.charge_carrier = self.config['device']['characteristics']['charge_carrier'] + self.operation_mode = self.config['device']['characteristics']['operation_mode'] + + # Set the voltage sign for the gates, based on the charge carrier and mode of the device + + if (self.charge_carrier, self.operation_mode) == ('e', 'acc'): + self.voltage_sign = +1 + + if (self.charge_carrier, self.operation_mode) == ('e', 'dep'): + self.voltage_sign = -1 + + if (self.charge_carrier, self.operation_mode) == ('h', 'acc'): + self.voltage_sign = -1 + + if (self.charge_carrier, self.operation_mode) == ('h', 'dep'): + self.voltage_sign = +1 + + # Get the device gates + + self.device_gates = self.config['device']['gates'] + + # Re-label all the gates as ohmics, barriers, leads, plungers, accumulation gates and screening gates + + self.ohmics = [] + self.barriers = [] + self.leads = [] + self.plungers = [] + self.accumulation = [] + self.screening = [] + + for gate, details in self.device_gates.items(): + + if details['type'] == 'ohmic': + self.ohmics.append(gate) + + if details['type'] == 'barrier': + self.barriers.append(gate) + + if details['type'] == 'lead': + self.leads.append(gate) + + if details['type'] == 'plunger': + self.plungers.append(gate) + + if details['type'] == 'accumulation': + self.accumulation.append(gate) + + if details['type'] == 'screening': + self.screening.append(gate) + + + self.all_gates = list(self.device_gates.keys()) + + # Contraints + + self.abs_max_current_triple_dot = self.config['device']['constraints']['abs_max_current_triple_dot'] + self.abs_max_current_SET_dot = self.config['device']['constraints']['abs_max_current_SET_dot'] + self.abs_max_gate_voltage = self.config['device']['constraints']['abs_max_gate_voltage'] + self.abs_max_gate_differential = self.config['device']['constraints']['abs_max_gate_differential'] + + # Equipment Setup + + self.voltage_divider_triple_dot = self.config['setup']['voltage_dividers']['voltage_divider_triple_dot'] + self.voltage_divider_SET_dot = self.config['setup']['voltage_dividers']['voltage_divider_SET_dot'] + self.triple_dot_preamp_bias = self.config['setup']['triple_dot_preamp']['preamp_bias'] + self.triple_dot_preamp_sensitivity = self.config['setup']['triple_dot_preamp']['preamp_sensitivity'] + self.SET_dot_preamp_bias = self.config['setup']['SET_dot_preamp']['preamp_bias'] + self.SET_dot_preamp_sensitivity = self.config['setup']['SET_dot_preamp']['preamp_sensitivity'] + + +class Bootstrapping(Protocol): + + def __init__(self, device_config): + super().__init__(device_config = device_config) + + self.noise_floor = None + + self.ground_device(instr_handler = gui.instrument_handler) + + self.measure_noise_floor() + + def ground_device(self, instr_handler): + + # First, we grab all the connected dacs and current values + + dacs_and_vals = {} + + for i in self.gates_to_dacs: + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + dacs_and_vals[i] = instr_handler.get_parameter( + instr, + param, + wait=True + ) + + print(dacs_and_vals) + + # Now, we create the sweep parameters + + targets = [] + + for i in dacs_and_vals: + + param = SweepParam( + parameter = i, + start = dacs_and_vals[i], + end = 0.0 + ) + + targets.append(param) + + print(targets) + + sweep_layer = SweepLayer( + targets = targets, + num_points = 200, + measurement_time = 0.1 + ) + + logger.info("Grounding Device...") + + future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Device Grounded!") + + def measure_noise_floor(self): + + # We collect the readout buffer for 1 minute and average the values to measure the noise floor. + + self.noise_floor = gui.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'], + t_avg = 0, + t_stop = 60 + ) + + def turn_on(self, ohmic_bias, screening_voltage, gate_voltage, num_points): + + # First we grab all the dacs and values to set to our ohmics + + ohmic_targets = [] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Ohmic": + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + ohmic_voltage = ohmic_bias / self.voltage_divider_SET_dot + + sparam = SweepParam( + parameter = param, + start = 0.0, + end = ohmic_voltage + ) + + ohmic_targets.append(sparam) + + print(ohmic_targets) + + sweep_layer = SweepLayer( + targets = ohmic_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + logger.info("Setting Ohmic Bias...") + + future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Ohmic Bias Set!") + + # Next, we set all constant initial voltages before Turn-On. For the Intel device, this is the screening gates + + screening_targets = [] + + screening_types = ["Dot Screening", "Sensor Screening", "Central Screening"] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] in screening_types: + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + sparam = SweepParam( + parameter = param, + start = 0.0, + end = screening_voltage + ) + + screening_targets.append(sparam) + + print(screening_targets) + + sweep_layer = SweepLayer( + targets = screening_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + logger.info("Setting Screening Gate Voltages...") + + future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Screening Gate Voltages Set!") + + # Now, we create the sweep parameters for all the gates + + gate_targets = [] + + excluded_types = ["Dot Ohmic", "Sensor Ohmic", "Dot Screening", "Sensor Screening", "Central Screening"] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] not in excluded_types: + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + sparam = SweepParam( + parameter = param, + start = 0, + end = gate_voltage + ) + + gate_targets.append(param) + + print(gate_targets) + + sweep_layer = SweepLayer( + targets = gate_targets, + num_points = num_points, + measurement_time = 0.3 + ) + + logger.info("Device Turn-On Starting...") + + future = gui.experiment_handler.do_sweep(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Device Turn-On Sweep Complete! Confirming Turn-On...") + + # Now, we determine if there was a measured current above the noise floor. If so, we fit our data to the ReLU function + + + + + + def pinch_off(self, gate_voltage, final_voltage, num_points): + + # We first construct a loop to pinch-off each individual gate + + excluded_types = ["Dot Ohmic", "Sensor Ohmic", "Dot Screening", "Sensor Screening", "Central Screening"] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] not in excluded_types: + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + sparam = SweepParam( + parameter = param, + start = gate_voltage, + end = final_voltage + ) + + sweep_layer = SweepLayer( + targets = sparam, + num_points = num_points, + measurement_time = 0.3 + ) + + logger.info(f"{self.device_gates[i]['label']} Starting Pinch-Off...") + + future = gui.experiment_handler.do_sweep(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info(f"{self.device_gates[i]['label']} Pinch-Off Complete! Confirming Pinch-Off...") + + """ + Now, we determine if there was a measured current comparable to the noise floor. + If so, we fit our data to the sigmoid function + + """ - def barrier_barrier_sweep(): - pass - def coulomb_blockade_sweep(): + + def SET_current_check(self, instr_handler, minimum_current, maximum_current): + + # First, we need to read the current and check if it is above or below the current values specified. + + self.current_level = gui.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'], + t_avg = 0, + t_stop = 60 + ) + + # Here, we get the current accumulation voltages + + included_types = ['Dot Accumulation', 'Sensor Accumulation'] + + self.accumulation_voltages = {} + + for i in self.gates_to_dacs: + + if i in included_types: + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + self.accumulation_voltages[i] = instr_handler.get_parameter( + instr, + param, + wait=True + ) + + if self.current_level > maximum_current: + + # We reduce the voltages on the accumulation gates by 1 mV + + self.accumulation_voltages -= 1e-3 + + for i in self.accumulation_voltages: + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + instr_handler.set_parameter( + instr, + {param: self.accumulation_voltages[i]}, + wait=True + ) + + self.SET_current_check(minimum_current, maximum_current) + + elif self.current_level < minimum_current: + + # We increase the voltages on the accumulation gates by 1 mV + + self.accumulation_voltages += 1e-3 + + for i in self.accumulation_voltages: + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + instr_handler.set_parameter( + instr, + {param: self.accumulation_voltages[i]}, + wait=True + ) + + self.SET_current_check(minimum_current, maximum_current) + + else: + pass + + def barrier_barrier_sweep(self, lower_voltages, upper_voltages, num_points): + + # First, we gather the lower voltages to which we set our barriers + + barrier_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + barrier_dacs_and_vals[i] = gui.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + print(barrier_dacs_and_vals) + + barrier_targets = [] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + sparam = SweepParam( + parameter = param, + start = barrier_dacs_and_vals[i], + end = upper_voltages[i] + ) + + barrier_targets.append(sparam) + + print(barrier_targets) + + sweep_layer = SweepLayer( + targets = barrier_targets, + num_points = num_points, + measurement_time = 0.05 + ) + + logger.info("Setting Initial Barrier Voltages...") + + future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Initial Barrier Voltages Set!") + + # Now, we create the sweep parameters for all the gates + + gate_targets_dots = [] + + gate_targets_sensors = [] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Dot Barrier": + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + sparam = SweepParam( + parameter = param, + start = upper_voltages[i], + end = lower_voltages[i] + ) + + gate_targets_dots.append(param) + + elif self.device_gates[i]['type'] == "Sensor Barrier": + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + sparam = SweepParam( + parameter = param, + start = upper_voltages[i], + end = lower_voltages[i] + ) + + gate_targets_sensors.append(param) + + print(gate_targets_dots) + print(gate_targets_sensors) + + for first, second in zip(gate_targets_dots, gate_targets_dots[1:]): + + sweep_layer = SweepLayer( + targets = [first, second], + num_points = num_points, + measurement_time = 0.05 + ) + + logger.info("Dot Barrier-Barrier Scan Starting...") + + future = gui.experiment_handler.do_sweep(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Dot Barrier Scan Complete! Finding Set Points...") + + # Now, we ensure that the charge sensor has an appropriate current level before tuning the barriers + + self.SET_current_check(1e-9, 5e-9) + + for first, second in zip(gate_targets_sensors, gate_targets_sensors[1:]): + + sweep_layer = SweepLayer( + targets = [first, second], + num_points = num_points, + measurement_time = 0.05 + ) + + logger.info("Sensor Barrier-Barrier Scan Starting...") + + future = gui.experiment_handler.do_sweep(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Sensor Barrier-Barrier Scan Complete! Finding Working Points...") + + def coulomb_blockade_sweep(self, barrier_voltages, lower_voltage, upper_voltage, num_points): + + # First, we set the barriers to the lower voltages + + barrier_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + barrier_dacs_and_vals[i] = gui.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + print(barrier_dacs_and_vals) + + barrier_targets = [] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + sparam = SweepParam( + parameter = param, + start = barrier_dacs_and_vals[i], + end = barrier_voltages[i] + ) + + barrier_targets.append(sparam) + + print(barrier_targets) + + sweep_layer = SweepLayer( + targets = barrier_targets, + num_points = num_points, + measurement_time = 0.05 + ) + + logger.info("Setting Initial Barrier Voltages...") + + future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Initial Barrier Voltages Set!") + + # Now, we define the sensor plunger sweeps + + sensor_plunger_targets = [] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + instr, param = p.parameter.split('.', 1) + + sparam = SweepParam( + parameter = param, + start = lower_voltage, + end = upper_voltage + ) + + sensor_plunger_targets.append(sparam) + + print(sensor_plunger_targets) + + sweep_layer = SweepLayer( + targets = sensor_plunger_targets, + num_points = num_points, + measurement_time = 0.05 + ) + + logger.info("Charge Sensor Plunger Sweep Starting...") + + future = gui.experiment_handler.do_sweep(sweep = sweep_layer, + instrument_handler = gui.instrument_handler) + + print(future) + + logger.info("Ohmic Bias Set!") + pass def coulomb_diamonds(): pass +class GlobalChargeTuning(Protocol): + + def __init__(self, device_config): + super().__init__(device_config = device_config) + def tune_lead_dot_tunneling(): pass -class CoarseTuning: - def plunger_plunger_sweep(): pass -class VirtualGating: +class VirtualGating(Protocol): + + def __init__(self, device_config): + super().__init__(device_config = device_config) def lever_arm_matrix(): pass -class ChargeStateTuning: +class ChargeStateTuning(Protocol): + + def __init__(self, device_config): + super().__init__(device_config = device_config) def determine_charge_states(): pass -class FineTuning: +class FineTuning(Protocol): + + def __init__(self, device_config): + super().__init__(device_config = device_config) def rabi_oscilations(): pass diff --git a/src/experiment_base.py b/src/experiment_base.py index 9a8a3a4..f819128 100644 --- a/src/experiment_base.py +++ b/src/experiment_base.py @@ -15,14 +15,8 @@ from datetime import datetime import time -import threading import numpy as np from dataclasses import dataclass -from collections.abc import Callable -from typing import Any -from qcodes.station import Station -from instrument_handler import TunerFuture -from experiment_handler import ExperimentThread, experiment_job from tunerlog import TunerLog logger = TunerLog('Exp. Base') @@ -310,8 +304,6 @@ def _build_reset_layers(self, idx, start_setpoints, end_setpoints, num_points=10 for p in layer.targets: param = p.parameter - print(f"param: {param}") - v_start = start_setpoints v_end = end_setpoints @@ -325,11 +317,10 @@ def _build_reset_layers(self, idx, start_setpoints, end_setpoints, num_points=10 end=v_end ) - print(f"new_p: {new_p}") - new_targets.append(new_p) - # Recreate layer with higher resolution + # Recreate layer + new_layer = type(layer)( targets=new_targets, num_points=num_points, diff --git a/src/experiment_handler.py b/src/experiment_handler.py index e72f83f..1e53007 100644 --- a/src/experiment_handler.py +++ b/src/experiment_handler.py @@ -158,8 +158,7 @@ def do_sweep(self, instrument_handler, current_setpoints = {}, wait: bool = True, - timeout: float = 60, - filename: str = "sweep.csv"): + timeout: float = 60): future = TunerFuture() diff --git a/src/gui.py b/src/gui.py index 999a04c..441d9e1 100644 --- a/src/gui.py +++ b/src/gui.py @@ -26,6 +26,8 @@ import os, sys from tunerlog import TunerLog from experiment_base import SweepParam, SweepLayer, Sweep +from autotuning_protocol import Protocol + class RandomDummy(DummyInstrument): ''' @@ -65,7 +67,7 @@ def __init__(self): self.logger = TunerLog("TunerGUI") self.start_time = time.monotonic() - self.station = Station(config_file = "../configs/test_station.yaml") + self.station = Station(config_file = "../configs/dummy_station.yaml") self.station_lock = threading.Lock() self.instrument_handler = create_buffer_instance(self.station, self.station_lock) @@ -83,12 +85,12 @@ def init_spi_rack(instrument: Instrument, *args): args[0].instrument_snapshot(instrument.module1.dac0) return - self.instrument_handler.add_instrument("agilent_left", init_agilent) + """ self.instrument_handler.add_instrument("agilent_left", init_agilent) self.instrument_handler.add_instrument("agilent_right", init_agilent) self.instrument_handler.add_instrument("spi_rack", init_spi_rack, self.logger) self.instrument_handler.monitor_parameter('agilent_left', ['volt']) - self.instrument_handler.monitor_parameter('agilent_right', ['volt']) + self.instrument_handler.monitor_parameter('agilent_right', ['volt']) """ self.abort_signal = threading.Event() @@ -129,13 +131,10 @@ def root_page(self): with ui.tab_panel('Setup'): - self.results_plot_panel() - - self.instr = ui.button('Connect to instruments', on_click = self.experiment_progress_bar) - self.autotune = ui.button('Autotune') - - config_files = os.listdir('../configs') - config_dict = {i : config_files[i] for i in range(len(config_files))} + self.autotune = ui.button( + 'Autotune', + on_click = Protocol(device_config = '../configs/Intel_Config.yaml') + ) with ui.tab_panel('Bootstrapping'): diff --git a/src/write_control.py b/src/write_control.py deleted file mode 100644 index 48f4074..0000000 --- a/src/write_control.py +++ /dev/null @@ -1,1069 +0,0 @@ -''' -File: write_control.py -Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) - -This file contains the WriteControl class, which handles all setting of values to instruments, -including sweeps and static voltage configurations. - -Currently, QCodes functions are used to carry out sweeps of instrument parameters, however in-house sweep functions -are currently in development. -''' - -# Import modules - -import yaml, datetime, sys, time, os, shutil, json,re -from pathlib import Path - -import pandas as pd -import math -import numpy as np - -import scipy as sp -from scipy.ndimage import convolve - -import matplotlib.pyplot as plt -import matplotlib.cm as cm - -from typing import List, Dict - -import qcodes as qc -from qcodes.dataset import AbstractSweep, Measurement -from qcodes.dataset.dond.do_nd_utils import ActionsT -from qcodes.parameters import ParameterBase -import numpy.typing as npt - -import skimage -from skimage.transform import probabilistic_hough_line -from skimage.feature import canny -from skimage.filters import threshold_otsu -from skimage.morphology import diamond, rectangle # noqa - -import logging -from colorlog import ColoredFormatter -import sys - -from nicegui import ui -import threading - - -class LinSweep_SIM928(AbstractSweep[np.float64]): - - """ - Linear sweep. - - Args: - param: Qcodes parameter to sweep. - start: Sweep start value. - stop: Sweep end value. - num_points: Number of sweep points. - delay: Time in seconds between two consecutive sweep points. - post_actions: Actions to do after each sweep point. - get_after_set: Should we perform a get on the parameter after setting it - and store the value returned by get rather than the set value in the dataset. - """ - - def __init__( - self, - param: ParameterBase, - start: float, - stop: float, - num_points: int, - delay: float = 0, - post_actions: ActionsT = (), - get_after_set: bool = False, - ): - self._param = param - self._start = start - self._stop = stop - self._num_points = num_points - self._delay = delay - self._post_actions = post_actions - self._get_after_set = get_after_set - - def get_setpoints(self) -> npt.NDArray[np.float64]: - """ - Linear (evenly spaced) numpy array for supplied start, stop and - num_points. - """ - array = np.linspace(self._start, self._stop, self._num_points).round(3) - # below_two = array[np.where(array < 2)].round(3) - # above_two = array[np.where(array >= 2)].round(2) - # array = np.concatenate((below_two, above_two)) - - return array - - @property - def param(self) -> ParameterBase: - return self._param - - @property - def delay(self) -> float: - return self._delay - - @property - def num_points(self) -> int: - return self._num_points - - @property - def post_actions(self) -> ActionsT: - return self._post_actions - - @property - def get_after_set(self) -> bool: - return self._get_after_set - - @property - def setpoints(self) -> npt.NDArray[np.float64]: - return self.get_setpoints() - -class WriteControl: - - def __init__(self, - logger, - config: str, - tuner_config: str, - station_config: str, - save_dir: str) -> None: - - """ - Initializes an InstrumentControl object. This class takes care of all connections and communication to instruments - during your experiment. - - Args: - config (str): Path to .yaml file containing device information. - setup_config (str): Path to .yaml file containing experimental setup information. - tuner_config (str): Path to .yaml file containing tuner information. - station_config (str): Path to .yaml file containing QCoDeS station information - save_dir (str): Directory to save data and plots generated. - """ - - # First, we save all the config information - - self.logger = logger - self.config_file = config - self.tuner_config_file = tuner_config - self.station_config_file = station_config - self.save_dir = save_dir - - - # Now, we load the config files - - self.load_config_files() - - # After the config files are loaded, we set up a file where data is stored, using today's date - - todays_date = datetime.date.today().strftime("%Y-%m-%d") - self.db_folder = os.path.join(save_dir, f"{self.config['device']['characteristics']['name']}_{todays_date}") - os.makedirs(self.db_folder, exist_ok=True) - - # The following method creates a logger that will provide information to the user while the code is running - - self.logger.initialise_logger() - - # Now, we connect to the instruments specified in the config - - self.logger.attempt("connecting to station") - - # Using the Station class from qcodes, we can represent the physical setup of our experiment - - self.station = qc.Station(config_file=self.station_config_file) - - # Now, we attempt to load the voltage source(s) and readout device from the station config file - - voltage_sources = [] - - for voltage_source in self.voltage_source_names: - - Instrument = self.station.load_instrument(voltage_source) - voltage_sources.append(Instrument) - - self.voltage_source_1 = voltage_sources[0] - self.voltage_source_2 = voltage_sources[1] - - self.station.load_instrument(self.multimeter_name) - - self.drain_mm_device = getattr(self.station, self.multimeter_name) - - self.drain_volt = getattr(self.station, self.multimeter_name).volt - - self.logger.complete("\n") - - # Now, we change the names of the parameters to match the names provided in the yaml file - - channel_prefix = "" - for parameter, details in self.voltage_source.parameters.items(): - if details.unit == 'V': - pattern = r'(.*).*\d+.*' - matches = re.findall(pattern,parameter) - extractions = [match.strip() for match in matches] - channel_prefix = extractions[0] - if channel_prefix == "": - self.logger.error('unable to find prefix for channels') - - self.logger.info("changing parameters to match names in config.yaml file") - self.voltage_source.timeout(5 * 60) - - for gate, details in self.device_gates.items(): - - self.voltage_source.add_parameter( - name=gate, - parameter_class=qc.parameters.DelegateParameter, - source=getattr(self.voltage_source, channel_prefix+str(details['channel'])), - label=details['label'], - unit = details['unit'], - step=details['step'], - ) - self.logger.info(f"changed {channel_prefix+str(details['channel'])} to {gate}") - - # Creates the qcodes database and sets-up the experiment - - db_filepath = os.path.join(self.db_folder, f"experiments_{self.config['device']['characteristics']['name']}_{todays_date}.db") - qc.dataset.initialise_or_create_database_at( - db_filepath - ) - self.logger.info(f"database created/loaded @ {db_filepath}") - - self.logger.info(f"experiment created/loaded in database") - self.initialization_exp = qc.dataset.load_or_create_experiment( - 'Initialization', - sample_name=self.config['device']['characteristics']['name'] - ) - - # This next section copies the config files in case they get lost or changed - - self.logger.info(f"copying all of the config.yml files") - shutil.copy(self.station_config_file, self.db_folder) - shutil.copy(self.tuner_config_file, self.db_folder) - shutil.copy(self.config_file, self.db_folder) - - # Finally, we set up a dictionary to store all of the important results from our experiments - - self.results = {} - - self.results['turn_on'] = { - 'voltage': None, - 'current': None, - 'resistance': None, - 'saturation': None, - } - - for gate in self.barriers + self.leads: - self.results[gate] = { - 'pinch_off': {'voltage': None, 'width': None} - } - - for gate in self.barriers: - self.results[gate]['bias_voltage'] = None - - # Finally, we also ground the device before the experiment starts - - self.ground_device() - - return None - - def load_config_files(self): - - ''' - This method loads all relavent information from the configuration files provided. It is ran when an InstrumentControl - object is initialized. - ''' - - # Reads the tuner config information - - self.tuner_info = yaml.safe_load(Path(self.tuner_config_file).read_text()) - self.global_turn_on_info = self.tuner_info['global_turn_on'] - self.pinch_off_info = self.tuner_info['pinch_off'] - - # Reads the config information - - self.config = yaml.safe_load(Path(self.config_file).read_text()) - self.charge_carrier = self.config['device']['characteristics']['charge_carrier'] - self.operation_mode = self.config['device']['characteristics']['operation_mode'] - - # Sets the voltage sign for the gates, based on the charge carrier and mode of the device - - if (self.charge_carrier, self.operation_mode) == ('e', 'acc'): - self.voltage_sign = +1 - - if (self.charge_carrier, self.operation_mode) == ('e', 'dep'): - self.voltage_sign = -1 - - if (self.charge_carrier, self.operation_mode) == ('h', 'acc'): - self.voltage_sign = -1 - - if (self.charge_carrier, self.operation_mode) == ('h', 'dep'): - self.voltage_sign = +1 - - # Now, we retreive the device gates - - self.device_gates = self.config['device']['gates'] - - # Then, we re-label all the gates as ohmics, barriers, leads, plungers, accumulation gates and screening gates - - self.ohmics = [] - self.barriers = [] - self.leads = [] - self.plungers = [] - self.accumulation = [] - self.screening = [] - - for gate, details in self.device_gates.items(): - - if details['type'] == 'ohmic': - self.ohmics.append(gate) - - if details['type'] == 'barrier': - self.barriers.append(gate) - - if details['type'] == 'lead': - self.leads.append(gate) - - if details['type'] == 'plunger': - self.plungers.append(gate) - - if details['type'] == 'accumulation': - self.accumulation.append(gate) - - if details['type'] == 'screening': - self.screening.append(gate) - - self.all_gates = list(self.device_gates.keys()) - - # Finally, we determine voltage and current thresholds, as well as other information about the experimental setup - - self.abs_max_current = self.config['device']['constraints']['abs_max_current'] - self.abs_max_gate_voltage = self.config['device']['constraints']['abs_max_gate_voltage'] - self.abs_max_gate_differential = self.config['device']['constraints']['abs_max_gate_differential'] - - self.voltage_source_names = self.config['setup']['voltage_sources'] - self.multimeter_name = self.config['setup']['multimeter'] - self.voltage_divider = self.config['setup']['voltage_divider'] - self.preamp_bias = self.config['setup']['preamp_bias'] - self.preamp_sensitivity = self.config['setup']['preamp_sensitivity'] - self.voltage_resolution = self.config['setup']['voltage_resolution'] - - return None - - def set_voltage(self, - gates: str | list[str], - voltage: float): - - """ - This method allows the user to smoothly set any number of gates to the same final voltage value. If you would like to - set different gates to different voltage values, please use the set_voltage_configuration() method. - - Args: - gates (str | list[str]): A single gate name, written as a string, or a list of gate strings, containing the names of the gates - we wish to set. - voltage (float): The voltage we wish to set the given gate(s) to. - """ - - # First, if only one gate is input, we convert it to a list to make it easier to work with - - if isinstance(gates, str): - gates = [gates] - - # Then, we define a dictionary using the gates and voltage input by the user - - voltage_dict = dict(zip(gates, [voltage]*len(gates))) - - # This dictionary gets input into the more general method, set_voltage_configuration() - - self.set_voltage_configuration(self, voltage_configuration = voltage_dict) - - return None - - def set_voltage_configuration(self, - voltage_configuration: Dict[str, float] = {}, - stepsize: float = 1e-3): - - """ - This method allows the user to smoothly set a given voltage configuration. - - Args: - voltage_configuration (Dict[str, float]): A dictionary containing the names of the gates to be set and - the corresponding voltages the gates will be set to. - stepsize (float): The voltage stepsize for all the gates. Default is set to 1 mV. - """ - - # First, we determine which gates are being set. - - gates = list(voltage_configuration.keys()) - - # Then, we assert that the sign of the voltage we wish to set agrees with the device we are testing - - for gate in gates: - - assert np.sign(voltage_configuration[gate]) == np.sign(self.voltage_sign) or np.sign(voltage_configuration[gate]) == 0, f"Check voltage sign on {gate}" - - # Now, we set up some lists to hold the voltage values - - gate_params = {} - start_vals = {} - step_sizes = {} - - # Now, we map the gate to the source and save the correspondance - - gate_to_source = {gate: instrument for source_name, instrument in self.voltage_sources.items() - for gate in self.voltage_source_names_check[source_name]} - - # Now, we gather the parameters to set - - for gate, target in voltage_configuration.items(): - - instrument = gate_to_source[gate] - param = getattr(instrument, gate) - - gate_params[gate] = param - start_vals[gate] = float(param.get()) - - step_param = getattr(instrument, f"{gate}_step", None) - step_sizes[gate] = step_param() if step_param else stepsize - - # Now, we determine the number of steps needed for each gate - - steps_needed = {} - - for gate, target in voltage_configuration.items(): - - dv = abs(target - start_vals[gate]) - steps_needed[gate] = math.ceil(dv / step_sizes[gate]) - - max_steps = max(steps_needed.values()) - - # Finally, we conduct the ramp - - for step in range(1, max_steps + 1): - - for gate, target in voltage_configuration.items(): - - start = start_vals[gate] - step_size = step_sizes[gate] - - direction = np.sign(target - start) - value = start + direction * step * step_size - - if direction > 0: - value = min(value, target) - else: - value = max(value, target) - - gate_params[gate].set(value) - - for instrument in self.voltage_sources.values(): - delay_param = getattr(instrument, "smooth_timestep", None) - if delay_param: - time.sleep(delay_param()) - break - - def sweep_1d_linsweep(self, - gate: str, - maxV: float = None, - minV: float = None, - voltage_configuration: Dict[str, float] = {}, - dV: float = 10e-3) -> pd.DataFrame: - - # Bring device to voltage configuration - - if voltage_configuration is not None: - self.logger.info(f"setting voltage configuration: {voltage_configuration}") - self.set_voltage_configuration(voltage_configuration) - - # Default dV and maxV based on setup_config and config - - if dV is None: - dV = self.voltage_resolution - - if maxV is None: - maxV = self.voltage_sign * self.abs_max_gate_voltage - - # Ensure we stay in the allowed voltage space - - assert np.sign(maxV) == self.voltage_sign, self.logger.error("Double check the sign of the gate voltage (maxV) for your given device.") - assert np.sign(minV) == self.voltage_sign or np.sign(minV) == 0, self.logger.error("Double check the sign of the gate voltage (minV) for your given device.") - - # Set up gate sweeps - - num_steps = self.calculate_num_of_steps(minV, maxV, dV) - - gates_involved = gate - - self.logger.info(f"setting {gates_involved} to {minV} V") - - self.set_voltage_configuration(gates_involved, minV) - - sweep_list = [] - - for voltage_source in self.voltage_sources.items(): - - for gate_name in gates_involved: - - if gate_name in self.voltage_source_names_check[voltage_source[0]]: - - print(self.voltage_source_names_check[voltage_source[0]][gate_name]) - - param = voltage_source[1][gate_name] - - sweep_list.append( - LinSweep_SIM928(param, minV, maxV, num_steps, get_after_set=False) - ) - - # Execute the measurement - self.logger.attempt(f"sweeping {gates_involved} together from {minV} V to {maxV} V") - - result = qc.dataset.dond( - qc.dataset.TogetherSweep( - *sweep_list - ), - self.drain_volt, - break_condition=self._check_break_conditions, - measurement_name='Device Turn On', - exp=self.initialization_exp, - show_progress=True - ) - - self.logger.complete('\n') - - return None - - def sweep_2d_linsweep(self, - P1: str = None, - P2: str = None, - P1_bounds: tuple = (None, None), - P2_bounds: tuple = (None, None), - dV: float | tuple = None, - voltage_configuration: dict = None) -> tuple[pd.DataFrame, plt.Axes]: - - # Bring device to voltage configuration - if voltage_configuration is not None: - self.logger.info(f"setting voltage configuration: {voltage_configuration}") - self.set_voltage_configuration(voltage_configuration) - else: - self.logger.info(f"setting {self.leads} to {self.results['turn_on']['saturation']} V") - self.set_voltage(self.leads, self.results['turn_on']['saturation']) - - # Parse dV from user - if dV is None: - dV_P1 = self.voltage_resolution - dV_P2 = self.voltage_resolution - elif type(dV) is float: - dV_P1 = dV - dV_P2 = dV - elif type(dV) is tuple: - dV_P1, dV_P2 = dV - else: - self.logger.error("invalid dV") - return - - # Double check device bounds - minV_P1, maxV_P1 = P1_bounds - minV_P2, maxV_P2 = P2_bounds - - if minV_P1 is None: - minV_P1 = self.results[P1]['pinch_off']['voltage'] - else: - assert np.sign(minV_P1) == self.voltage_sign, self.logger.error("double check the sign of the gate voltage (minV) for B1.") - - if minV_P2 is None: - minV_P2 = self.results[P2]['pinch_off']['voltage'] - else: - assert np.sign(minV_P2) == self.voltage_sign, self.logger.error("double check the sign of the gate voltage (minV) for B2.") - - if maxV_P1 is None: - if self.voltage_sign == 1: - maxV_P1 = min(self.results[P1]['pinch_off']['voltage']+self.voltage_sign*self.results[P1]['pinch_off']['width'], self.results['turn_on']['saturation']) - elif self.voltage_sign == -1: - maxV_P1 = max(self.results[P1]['pinch_off']['voltage']+self.voltage_sign*self.results[P1]['pinch_off']['width'], self.results['turn_on']['saturation']) - else: - assert np.sign(maxV_P1) == self.voltage_sign, self.logger.error("double check the sign of the gate voltage (maxV) for B1.") - - if maxV_P2 is None: - if self.voltage_sign == 1: - maxV_P2 = min(self.results[P2]['pinch_off']['voltage']+self.voltage_sign*self.results[P2]['pinch_off']['width'], self.results['turn_on']['saturation']) - elif self.voltage_sign == -1: - maxV_P2 = max(self.results[P2]['pinch_off']['voltage']+self.voltage_sign*self.results[P2]['pinch_off']['width'], self.results['turn_on']['saturation']) - else: - assert np.sign(maxV_P2) == self.voltage_sign, self.logger.error("double check the sign of the gate voltage (maxV) for B2.") - - self.logger.info(f"setting {P1} to {maxV_P1} V") - self.logger.info(f"setting {P2} to {maxV_P2} V") - - self.set_voltage_configuration({P1: maxV_P1, P2: maxV_P2}) - - def smooth_reset(): - - """ - Resets the inner loop variable smoothly back to the starting value - """ - - self.set_gates_to_voltage([P2], maxV_P2) - - num_steps_B1 = self.calculate_num_of_steps(minV_P1, maxV_P1, dV_P1) - num_steps_B2 = self.calculate_num_of_steps(minV_P2, maxV_P2, dV_P2) - - self.logger.attempt("barrier barrier scan") - - self.logger.info(f"stepping {P1} from {maxV_P1} V to {minV_P1} V") - self.logger.info(f"sweeping {P2} from {maxV_P2} V to {minV_P2} V") - - gates = self.barriers - param_check = [] - - for voltage_source in self.voltage_sources.items(): - - for gate_name in gates: - - if gate_name in self.voltage_source_names_check[voltage_source[0]]: - - print(self.voltage_source_names_check[voltage_source[0]][gate_name]) - - param = voltage_source[1][gate_name] - - param_check.append(param) - - result = qc.dataset.do2d( - param_check[0], # outer loop - maxV_P1, - minV_P1, - num_steps_B1, - param_check[1], # inner loop - maxV_P2, - minV_P2, - num_steps_B2, - self.drain_volt, - after_inner_actions = [smooth_reset], - set_before_sweep=True, - show_progress=True, - measurement_name='Barrier Barrier Sweep', - exp=self.initialization_exp - ) - self.logger.complete("\n") - - self.logger.info(f"returning gates {P1}, {P2} to {maxV_P1} V, {maxV_P2} V respectively") - self.set_voltage([P1], maxV_P1) - self.set_voltage([P2], maxV_P2) - - return None - - def sweep_nd_linsweep(self): - pass - - def sweep_1d_measurement(self, - maxV: float = None, - minV: float = None, - voltage_configuration: Dict[str, float] = {}, - dV: float = 10e-3) -> pd.DataFrame: - - # Bring device to voltage configuration - - if voltage_configuration is not None: - self.logger.info(f"setting voltage configuration: {voltage_configuration}") - self.set_voltage_configuration(voltage_configuration) - - # Default values - - if dV is None: - dV = self.voltage_resolution - - if maxV is None: - maxV = self.voltage_sign * self.abs_max_gate_voltage - - # Safety checks - - assert np.sign(maxV) == self.voltage_sign - assert np.sign(minV) == self.voltage_sign or np.sign(minV) == 0 - - # Gates involved - - gates_involved = self.barriers + self.leads + self.accumulation + self.plungers - - self.logger.info(f"setting {gates_involved} to {minV} V") - - self.set_voltage_configuration(gates_involved, minV) - - # Number of steps - - num_steps = self.calculate_num_of_steps(minV, maxV, dV) - - # Build parameter list - - gate_params = [] - - for voltage_source in self.voltage_sources.items(): - - for gate_name in gates_involved: - - if gate_name in self.voltage_source_names_check[voltage_source[0]]: - - param = voltage_source[1][gate_name] - gate_params.append(param) - - # Create sweep values - - sweep_vals = np.linspace(minV, maxV, num_steps) - - # Setup measurement - - meas = Measurement(exp=self.initialization_exp) - - # Register parameters - for param in gate_params: - meas.register_parameter(param) - - meas.register_parameter(self.drain_volt, setpoints=tuple(gate_params)) - - # Execute sweep - self.logger.attempt( - f"sweeping {gates_involved} together from {minV} V to {maxV} V" - ) - - with meas.run() as datasaver: - - for v in sweep_vals: - - # set all gates together - for param in gate_params: - param.set(v) - - # measurement - drain = self.drain_volt.get() - - results = [(param, v) for param in gate_params] - results.append((self.drain_volt, drain)) - - datasaver.add_result(*results) - - # break condition - if self._check_break_conditions(drain): - break - - self.logger.complete('\n') - - return None - - def sweep_2d_measurement(self, - P1: str = None, - P2: str = None, - P1_bounds: tuple = (None, None), - P2_bounds: tuple = (None, None), - dV: float | tuple = None, - voltage_configuration: dict = None) -> tuple[pd.DataFrame, plt.Axes]: - - # Bring device to voltage configuration - if voltage_configuration is not None: - self.logger.info(f"setting voltage configuration: {voltage_configuration}") - self.set_voltage_configuration(voltage_configuration) - else: - self.logger.info(f"setting {self.leads} to {self.results['turn_on']['saturation']} V") - self.set_voltage(self.leads, self.results['turn_on']['saturation']) - - # Parse dV - if dV is None: - dV_P1 = self.voltage_resolution - dV_P2 = self.voltage_resolution - elif type(dV) is float: - dV_P1 = dV - dV_P2 = dV - elif type(dV) is tuple: - dV_P1, dV_P2 = dV - else: - self.logger.error("invalid dV") - return - - # Bounds - minV_P1, maxV_P1 = P1_bounds - minV_P2, maxV_P2 = P2_bounds - - # (same bounds logic as your code omitted here for brevity) - - self.logger.info(f"setting {P1} to {maxV_P1} V") - self.logger.info(f"setting {P2} to {maxV_P2} V") - - self.set_voltage_configuration({P1: maxV_P1, P2: maxV_P2}) - - # Step counts - num_steps_B1 = self.calculate_num_of_steps(minV_P1, maxV_P1, dV_P1) - num_steps_B2 = self.calculate_num_of_steps(minV_P2, maxV_P2, dV_P2) - - # Generate sweep arrays - P1_vals = np.linspace(maxV_P1, minV_P1, num_steps_B1) - P2_vals = np.linspace(maxV_P2, minV_P2, num_steps_B2) - - self.logger.attempt("barrier barrier scan") - - self.logger.info(f"stepping {P1} from {maxV_P1} V to {minV_P1} V") - self.logger.info(f"sweeping {P2} from {maxV_P2} V to {minV_P2} V") - - # Resolve QCoDeS parameters - gates = self.barriers - param_check = [] - - for voltage_source in self.voltage_sources.items(): - - for gate_name in gates: - - if gate_name in self.voltage_source_names_check[voltage_source[0]]: - param = voltage_source[1][gate_name] - param_check.append(param) - - P1_param = param_check[0] - P2_param = param_check[1] - - # Setup measurement - meas = Measurement(exp=self.initialization_exp) - - meas.register_parameter(P1_param) - meas.register_parameter(P2_param) - meas.register_parameter(self.drain_volt, setpoints=(P1_param, P2_param)) - - # Inner reset function - def smooth_reset(): - - self.set_gates_to_voltage([P2], maxV_P2) - - # Run measurement - with meas.run() as datasaver: - - for v1 in P1_vals: - - # set outer parameter - P1_param.set(v1) - - for v2 in P2_vals: - - # set inner parameter - P2_param.set(v2) - - drain = self.drain_volt.get() - - datasaver.add_result( - (P1_param, v1), - (P2_param, v2), - (self.drain_volt, drain), - ) - - # reset inner sweep - smooth_reset() - - self.logger.complete("\n") - - # Return gates to starting values - self.logger.info(f"returning gates {P1}, {P2} to {maxV_P1} V, {maxV_P2} V respectively") - - self.set_voltage([P1], maxV_P1) - self.set_voltage([P2], maxV_P2) - - return None - - def sweep_nd_measurement(self): - pass - - def sweep_1d(self, - gate: str, - startV: float = None, - endV: float = None, - voltage_configuration: Dict[str, float] = {}, - dV: float = 10e-3) -> pd.DataFrame: - - """ - This method allows the user to sweep a given gate parameter from a pre-defined start and end point, with a given stepsize. - - Args: - gate (str): The gate to be swept. - - startV (float): The intial voltage for the sweep, specified in volts. - - endV (float): The final voltage for the sweep, specified in volts. - - voltage_configuration (Dict[str, float]): A dictionary containing the names of the gates to be set and - the corresponding voltages the gates will be set to. - - dV (float): The voltage stepsize for all the gates. The default is set to 1 mV. - """ - - # First, we set the initial voltage configuration specified - - if voltage_configuration is not None: - self.logger.info(f"setting voltage configuration: {voltage_configuration}") - self.set_voltage_configuration(voltage_configuration) - - # Then, we set the default dV and V bounds based on the config and setup_config files - - if dV is None: - dV = self.voltage_resolution - - if startV is None: - minV = self.voltage_sign * self.abs_max_gate_voltage - - if endV is None: - maxV = self.voltage_sign * self.abs_max_gate_voltage - - assert np.sign(endV) == self.voltage_sign, self.logger.error("Double check the sign of the gate voltage (maxV) for your given device.") - assert np.sign(startV) == self.voltage_sign or np.sign(startV) == 0, self.logger.error("Double check the sign of the gate voltage (minV) for your given device.") - - # Now, we collect the gate involved and set it to the initial voltage - - gates_involved = gate - - self.logger.info(f"setting {gates_involved} to {startV} V") - - self.set_voltage_configuration(gates_involved, startV) - - # First, we determine which gates are being set. - - gates = list(voltage_configuration.keys()) - - # Then, we assert that the sign of the voltage we wish to set agrees with the device we are testing - - for gate in gates: - - assert np.sign(voltage_configuration[gate]) == np.sign(self.voltage_sign) or np.sign(voltage_configuration[gate]) == 0, f"Check voltage sign on {gate}" - - # Now, we set up some lists to hold the voltage values - - gate_params = {} - start_vals = {} - step_sizes = {} - - # Now, we map the gate to the source and save the correspondance - - gate_to_source = {gate: instrument for source_name, instrument in self.voltage_sources.items() - for gate in self.voltage_source_names_check[source_name]} - - # Now, we gather the parameters to set - - for gate, target in voltage_configuration.items(): - - instrument = gate_to_source[gate] - param = getattr(instrument, gate) - - gate_params[gate] = param - start_vals[gate] = float(param.get()) - - step_param = getattr(instrument, f"{gate}_step", None) - step_sizes[gate] = step_param() if step_param else stepsize - - # Now, we determine the number of steps needed for each gate - - steps_needed = {} - - for gate, target in voltage_configuration.items(): - - dv = abs(target - start_vals[gate]) - steps_needed[gate] = math.ceil(dv / step_sizes[gate]) - - max_steps = max(steps_needed.values()) - - # Finally, we conduct the ramp - - for step in range(1, max_steps + 1): - - for gate, target in voltage_configuration.items(): - - start = start_vals[gate] - step_size = step_sizes[gate] - - direction = np.sign(target - start) - value = start + direction * step * step_size - - if direction > 0: - value = min(value, target) - else: - value = max(value, target) - - gate_params[gate].set(value) - - for instrument in self.voltage_sources.values(): - delay_param = getattr(instrument, "smooth_timestep", None) - if delay_param: - time.sleep(delay_param()) - break - - def check_break_conditions(self): - - # Go through device break conditions to see if anything is flagged, - # should return a Boolean. - - # breakConditionsDict = { - # 0: 'Maximum current is exceeded.', - # 1: 'Maximum ohmic bias is exceeded.', - # 2: 'Maximum gate voltage is exceeded.', - # 3: 'Maximum gate differential is exceeded.', - # } - - # MAX CURRENT - - isExceedingMaxCurrent = np.abs(self._get_drain_current()) > self.abs_max_current - # time.sleep(0.1) - - # MAX BIAS - - # flag = [] - # for gate_name in self.ohmics: - # gate_voltage = getattr(self.voltage_source, f'{gate_name}')() - # if np.abs(gate_voltage * self.voltage_divider) > self.abs_max_ohmic_bias: - # flag.append(True) - # else: - # flag.append(False) - # isExceedingMaxOhmicBias = np.array(flag).any() - # time.sleep(0.1) - - # MAX GATE VOLTAGE - - # flag = [] - # for gate_name in self.all_gates: - # gate_voltage = getattr(self.voltage_source, f'{gate_name}')() - # if np.abs(gate_voltage) > self.abs_max_gate_voltage: - # flag.append(True) - # else: - # flag.append(False) - # isExceedingMaxGateVoltage = np.array(flag).any() - # time.sleep(0.1) - - # # MAX GATE DIFFERENTIAL - - # flag = [] - # gates_to_check = self.barriers + self.leads - # for i in range(len(gates_to_check)): - # for j in range(i+1, len(gates_to_check)): - # gate_voltage_i = getattr(self.voltage_source, f'{self.all_gates[i]}')() - # gate_voltage_j = getattr(self.voltage_source, f'{self.all_gates[j]}')() - # # Check if the absolute difference between gate voltages is greater than 0.5 - # if np.abs(gate_voltage_i - gate_voltage_j) >= self.abs_max_gate_differential: - # flag.append(True) - # else: - # flag.append(False) - # isExceedingMaxGateDifferential = np.array(flag).any() - # time.sleep(0.1) - - listOfBreakConditions = [ - isExceedingMaxCurrent, - # isExceedingMaxOhmicBias, - # isExceedingMaxGateVoltage, - # isExceedingMaxGateDifferential, - ] - isExceeded = np.array(listOfBreakConditions).any() - - # breakConditions = np.where(np.any(listOfBreakConditions == True))[0] - # if len(breakConditions) != 0: - # for index in breakConditions.tolist(): - # print(breakConditionsDict[index]+"\n") - - return isExceeded - - def calculate_num_of_steps(self, - minV: float, - maxV: float, - dV: float): - """Calculates the number of steps required for a sweep. - - Args: - minV (float): Minimum voltage (V) - maxV (float): Maximum voltage (V) - dV (float): Step size (V) - - Returns: - None: - """ - - return round(np.abs(maxV-minV) / dV) + 1 \ No newline at end of file From 9ab38835e92c40f911faf3d41d6d8951dcfd5833 Mon Sep 17 00:00:00 2001 From: VanOschB <118694763+VanOschB@users.noreply.github.com> Date: Fri, 22 May 2026 11:15:26 -0400 Subject: [PATCH 23/36] Initial Testing Ready Added files and modified gui.py to allow for testing the experimental setup --- configs/Intel_Config_Test.yaml | 31 +++++++++++++++++++++++++++++++ src/autotuning_protocol.py | 2 ++ src/data_analysis_test.ipynb | 0 src/gui.py | 23 +++++++++++++++++++++-- 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 configs/Intel_Config_Test.yaml create mode 100644 src/data_analysis_test.ipynb diff --git a/configs/Intel_Config_Test.yaml b/configs/Intel_Config_Test.yaml new file mode 100644 index 0000000..0e4e566 --- /dev/null +++ b/configs/Intel_Config_Test.yaml @@ -0,0 +1,31 @@ +device: + + characteristics: + name: 3D1S_w151_1 + charge_carrier: e + operation_mode: acc + + constraints: + abs_max_current_triple_dot: 20e-9 + abs_max_current_SET_dot: 10e-9 + abs_max_gate_differential: 1.5 + abs_max_gate_voltage: 1.5 + abs_max_ohmic_bias: 1e-3 + + gates: + S0: + label: Left Dot Ohmic + type: Dot Ohmic + channel: module2.dac16 + SG1: + label: Horizontal Screening Gate + type: Central Screening + channel: module2.dac15 + AC0: + label: Left Accumulation Gate + type: Dot Accumulation + channel: module2.dac14 + B0: + label: Left Dot Barrier Gate + type: Dot Barrier + channel: module2.dac13 \ No newline at end of file diff --git a/src/autotuning_protocol.py b/src/autotuning_protocol.py index 482fa76..c63b873 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -60,6 +60,8 @@ def __init__(self, device_config): self.gates_to_dacs[i] = self.device_gates[i]['channel'] + print(self.gates_to_dacs) + def _load_config_file(self, device_config): # Read the tuner config information diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb new file mode 100644 index 0000000..e69de29 diff --git a/src/gui.py b/src/gui.py index 441d9e1..7ebe23b 100644 --- a/src/gui.py +++ b/src/gui.py @@ -26,7 +26,7 @@ import os, sys from tunerlog import TunerLog from experiment_base import SweepParam, SweepLayer, Sweep -from autotuning_protocol import Protocol +from autotuning_protocol import Protocol, Bootstrapping, GlobalChargeTuning, VirtualGating, ChargeStateTuning class RandomDummy(DummyInstrument): @@ -67,7 +67,7 @@ def __init__(self): self.logger = TunerLog("TunerGUI") self.start_time = time.monotonic() - self.station = Station(config_file = "../configs/dummy_station.yaml") + self.station = Station(config_file = "../configs/Intel_Config_Test.yaml") self.station_lock = threading.Lock() self.instrument_handler = create_buffer_instance(self.station, self.station_lock) @@ -180,6 +180,11 @@ def root_page(self): on_click=self.run_test_sweep_3 ) + ui.button( + 'Run Bootstrapping', + on_click = self.run_bootstrapping + ) + self.debug_status = ui.label('Idle') with splitter1.after: @@ -353,6 +358,20 @@ def check_result(): check_result() + def run_bootstrapping(self): + + self.debug_status.set_text("Running Bootstrapping...") + self.logger.info("Bootstrapping Jobs queued") + + protocol = Protocol(device_config = r"C:\Users\bennt\OneDrive\Documents\GitHub\QuantumDotControl\configs\Intel_Config_Test.yaml") + + self.logger.info("Protocol Object Created") + + bootstrapping = Bootstrapping(device_config = r"C:\Users\bennt\OneDrive\Documents\GitHub\QuantumDotControl\configs\Intel_Config_Test.yaml") + + self.logger.info("Bootstrapping Completed!") + + def header(self): """ From fafa13335b3d0eea4ffaf7743c8f2220a8177164 Mon Sep 17 00:00:00 2001 From: VanOschB <118694763+VanOschB@users.noreply.github.com> Date: Wed, 27 May 2026 00:07:30 -0400 Subject: [PATCH 24/36] Final Test Setup Added Configuration files to test the autotuning code. --- configs/Intel_Config_SET.yaml | 58 +++++++++++++++++++ configs/Intel_Config_SET_Only.yaml | 87 ---------------------------- configs/Intel_Config_Triple_Dot.yaml | 74 +++++++++++++++++++++++ src/data_analysis_test.ipynb | 50 ++++++++++++++++ src/gui.py | 5 +- 5 files changed, 185 insertions(+), 89 deletions(-) create mode 100644 configs/Intel_Config_SET.yaml delete mode 100644 configs/Intel_Config_SET_Only.yaml create mode 100644 configs/Intel_Config_Triple_Dot.yaml diff --git a/configs/Intel_Config_SET.yaml b/configs/Intel_Config_SET.yaml new file mode 100644 index 0000000..36f5eae --- /dev/null +++ b/configs/Intel_Config_SET.yaml @@ -0,0 +1,58 @@ +device: + + characteristics: + name: 3D1S_w151_1 + charge_carrier: e + operation_mode: acc + + constraints: + abs_max_current_triple_dot: 20e-9 + abs_max_current_SET_dot: 10e-9 + abs_max_gate_differential: 1.5 + abs_max_gate_voltage: 1.5 + abs_max_ohmic_bias: 1e-3 + + gates: + S3: + label: Right Sensor Ohmic + type: Sensor Ohmic + channel: module1.dac11 + SG1: + label: Horizontal Screening Gate + type: Central Screening + channel: module1.dac12 + CG0b: + label: Sensor Screening Gate + type: Sensor Screening + channel: module1.dac13 + AC2: + label: Right Sensor Accumulation Gate + type: Sensor Accumulation + channel: module1.dac14 + F1 + AC3: + label: Left Sensor Accumulation Gate and Flanking Gates + type: Sensor Accumulation + channel: module1.dac15 + B20: + label: Left Sensor Barrier Gate + type: Sensor Barrier + channel: module1.dac16 + B21: + label: Right Sensor Barrier Gate + type: Sensor Barrier + channel: module2.dac14 + P20: + label: Sensor Plunger + type: Sensor Plunger + channel: module2.dac15 +setup: + triple_dot_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + SET_dot_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + voltage_dividers: + voltage_divider_triple_dot: 1.0e-3 + voltage_divider_SET_dot: 1.0e-3 + diff --git a/configs/Intel_Config_SET_Only.yaml b/configs/Intel_Config_SET_Only.yaml deleted file mode 100644 index 2c6e444..0000000 --- a/configs/Intel_Config_SET_Only.yaml +++ /dev/null @@ -1,87 +0,0 @@ -device: - - characteristics: - name: 3D1S_3 - charge_carrier: e - operation_mode: acc - - constraints: - abs_max_current: 13e-9 - abs_max_gate_differential: 2 - abs_max_gate_voltage: 2.0 - - gates: - S2: - label: Right Sensor Ohmic - type: ohmic - channel: 3 - voltage_source: sim900 - step: 0.005 - unit: V - SG1: - label: Horizontal Screening Gate - type: screening - channel: 1 - voltage_source: keithley2401 - step: 0.005 - unit: V - CG0b: - label: Sensor Screening Gate - type: screening - channel: 2 - voltage_source: sim900 - step: 0.005 - unit: V - AC2: - label: Right Sensor Accumulation Gate - type: accumulation - channel: 5 - voltage_source: sim900 - step: 0.005 - unit: V - AC3: - label: Left Sensor Accumulation Gate - type: accumulation - channel: 6 - voltage_source: sim900 - step: 0.005 - unit: V - F1: - label: Flanking Gates - type: flanking - channel: 4 - voltage_source: sim900 - step: 0.005 - unit: V - B20: - label: Left Sensor Barrier Gate - type: barrier - channel: 7 - voltage_source: sim900 - step: 0.005 - unit: V - B21: - label: Right Sensor Barrier Gate - type: barrier - channel: 8 - voltage_source: sim900 - step: 0.005 - unit: V - P20: - label: Sensor Plunger - type: plunger - channel: 1 - voltage_source: sim900 - step: 0.005 - unit: V -setup: - voltage_sources: - voltage_source_1: sim900 - voltage_source_2: keithley2401 - multimeter: agilent - preamp_bias: 0.0 - preamp_sensitivity: 1.0e-7 - voltage_divider: 1.0e-2 - voltage_resolution: 1.0e-3 - - diff --git a/configs/Intel_Config_Triple_Dot.yaml b/configs/Intel_Config_Triple_Dot.yaml new file mode 100644 index 0000000..3797eb5 --- /dev/null +++ b/configs/Intel_Config_Triple_Dot.yaml @@ -0,0 +1,74 @@ +device: + + characteristics: + name: 3D1S_w151_1 + charge_carrier: e + operation_mode: acc + + constraints: + abs_max_current_triple_dot: 20e-9 + abs_max_current_SET_dot: 10e-9 + abs_max_gate_differential: 1.5 + abs_max_gate_voltage: 1.5 + abs_max_ohmic_bias: 1e-3 + + gates: + S0: + label: Left Dot Ohmic + type: Dot Ohmic + channel: module1.dac7 + SG1: + label: Horizontal Screening Gate + type: Central Screening + channel: module1.dac11 + CG0a: + label: Dot Screening Gate + type: Dot Screening + channel: module1.dac12 + AC0: + label: Left Dot Accumulation Gate + type: Dot Accumulation + channel: module1.dac13 + AC1: + label: Right Dot Accumulation Gate + type: Dot Accumulation + channel: module1.dac14 + B0: + label: Left Barrier Gate + type: Dot Barrier + channel: module1.dac15 + B1: + label: Dot 1-2 Barrier Gate + type: Dot Barrier + channel: module1.dac16 + B2: + label: Dot 2-3 Barrier Gate + type: Dot Barrier + channel: module2.dac12 + B3: + label: Right Barrier Gate + type: Dot Barrier + channel: module2.dac13 + P0: + label: Left Dot Plunger + type: Dot Plunger + channel: module2.dac14 + P1: + label: Middle Dot Plunger + type: Dot Plunger + channel: module2.dac15 + P2: + label: Right Dot Plunger + type: Dot Plunger + channel: module2.dac16 +setup: + triple_dot_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + SET_dot_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + voltage_dividers: + voltage_divider_triple_dot: 1.0e-3 + voltage_divider_SET_dot: 1.0e-3 + diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index e69de29..8eb27f3 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -0,0 +1,50 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "da4a4e69", + "metadata": {}, + "source": [ + "## This notebook is used for testing the data analysis python code on the manual device tuning of the 3D1S_w151_1 Intel Device" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "05de62ea", + "metadata": {}, + "outputs": [], + "source": [ + "import data_analysis as da\n", + "import pandas as pd" + ] + }, + { + "cell_type": "markdown", + "id": "d9e6976a", + "metadata": {}, + "source": [ + "#### We start with the turn-on and pinch-off curves and attempt to fit to them:" + ] + }, + { + "cell_type": "markdown", + "id": "358c1b8c", + "metadata": {}, + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Tuner", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.20" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/gui.py b/src/gui.py index 7ebe23b..59170b2 100644 --- a/src/gui.py +++ b/src/gui.py @@ -83,14 +83,15 @@ def init_spi_rack(instrument: Instrument, *args): instrument.add_spi_module(8, 'D5a', 'module1') instrument.add_spi_module(7, 'D5a', 'module2') args[0].instrument_snapshot(instrument.module1.dac0) + instrument.module2.dac14(0.01) return - """ self.instrument_handler.add_instrument("agilent_left", init_agilent) + self.instrument_handler.add_instrument("agilent_left", init_agilent) self.instrument_handler.add_instrument("agilent_right", init_agilent) self.instrument_handler.add_instrument("spi_rack", init_spi_rack, self.logger) self.instrument_handler.monitor_parameter('agilent_left', ['volt']) - self.instrument_handler.monitor_parameter('agilent_right', ['volt']) """ + self.instrument_handler.monitor_parameter('agilent_right', ['volt']) self.abort_signal = threading.Event() From 9cd6a09aa58ea28a23349e7fe21f6e4b5be173db Mon Sep 17 00:00:00 2001 From: Drv5MC <116960417+Drv5MC@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:44:17 -0400 Subject: [PATCH 25/36] Data Analysis Debugging Debugged and added extra functionality to data_analysis.py. Tested it Jupyter Notebook with Intel device data collected. --- src/data_analysis.py | 907 ++++++++++++++++++++++++++++------- src/data_analysis_test.ipynb | 212 ++++++++ 2 files changed, 946 insertions(+), 173 deletions(-) diff --git a/src/data_analysis.py b/src/data_analysis.py index b3fe4c9..add79a7 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -17,15 +17,16 @@ import numpy.typing as npt import pandas as pd import cv2 -import signal +import scipy.signal as signal import matplotlib.cm as cm import matplotlib.pyplot as plt import matplotlib.lines as mlines from matplotlib.ticker import AutoMinorLocator -from matplotlib.patches import ConnectionPatch +from matplotlib.patches import ConnectionPatch, Rectangle from scipy.optimize import curve_fit +from scipy.special import expit from scipy.ndimage import convolve, map_coordinates, gaussian_filter1d import skimage @@ -46,140 +47,376 @@ from nicegui import ui def logarithmic(x, a, b, x0, y0): + """Logarithmic model used for curve fitting. + + Parameters: + x: independent variable array + a, b, x0, y0: fit parameters + """ return a * np.log(b*(x-x0)) + y0 def exponential(x, a, b, x0, y0): + """Exponential model used for curve fitting. + + Parameters: + x: independent variable array + a, b, x0, y0: fit parameters + """ return a * np.exp(b * (x-x0)) + y0 def sigmoid(x, a, b, x0, y0): - return a/(1+np.exp(b * (x-x0))) + y0 + """Sigmoid model used for turn-on / pinch-off fitting. + + Parameters: + x: independent variable array + a, b, x0, y0: fit parameters + """ + return a * expit(-b * (x - x0)) + y0 def linear(x, m, b): + """Simple linear model for fitting straight-line behavior.""" return m * x + b -def relu(x, a, x0, b): - return np.maximum(0, a * (x - x0) + b) +def relu(x, a, x0): + """ReLU-style model that is zero below x0 and linear above it.""" + return np.maximum(0, a * (x - x0)) def fit_to_function(x_data, y_data, - function: Callable): + function: Callable, + p0: list[float] = None, + print_results: bool = True): + """Fit a provided model function to x/y data using nonlinear least squares. + + Parameters: + x_data: independent variable values + y_data: dependent variable values + function: callable model to fit (e.g. sigmoid) + p0: optional initial guess for model parameters + print_results: whether to print fitted parameter values + + Returns: + params: model parameter names + popt: optimized parameter values + pcov: covariance matrix of parameter estimates + """ + + if p0 is None: + popt, pcov = curve_fit(function, x_data, y_data) + perr = np.sqrt(np.diag(pcov)) + else: + popt, pcov = curve_fit(function, x_data, y_data, p0=p0) + perr = np.sqrt(np.diag(pcov)) - popt, pcov = curve_fit(function, x_data, y_data) - perr = np.sqrt(np.diag(pcov)) - params = list(inspect.signature(function).parameters.keys())[1:] - for name, val, err in zip(params, popt, perr): - print(f"{name} = {val:.3f} ± {err:.3f}") + if print_results: + for name, val, err in zip(params, popt, perr): + print(f"{name} = {val:.3f} ± {err:.3f}") return params, popt, pcov -def pinch_off_curve_ranges(x_data, y_data): +def extract_turn_on_voltage(x_data: np.array, + y_data: np.array, + noisefloor: float, + plot_results: bool = True): + """Estimate the turn-on voltage from a gate-sweep current curve. + + This routine baseline-corrects the current, then finds the first + voltage where the current rises above the provided threshold. + """ # --- Data definitions --- x1 = np.array(x_data) y1 = np.array(y_data) - # --- Fit sigmoids --- + if y1[-1] < 0: + y1 = -y1 + + # --- Finding Turn-On Voltage --- + turnon_voltage = 0 + turnon_current = 0 + + for val in y1: + if val > noisefloor: + idx_turnon = np.where(y1 == val)[0][0] # get the index of the turn-on point + turnon_voltage = x1[idx_turnon] + turnon_current = y1[idx_turnon] + break + + # --- Plot data --- + + if plot_results: + + fig, ax = plt.subplots(figsize=(8,6)) + ax.plot(x1, y1, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') + ax.scatter(turnon_voltage, turnon_current, color='red', s=100, zorder=5, label='Turn-On Point') + ax.legend(fontsize=24, frameon=False, loc='upper left') + + # --- Labels and formatting --- + + ax.set_xlabel(r'V$_{gate}$ (V)', fontsize=35) + ax.set_ylabel('I (nA)', fontsize=35) + + ax.minorticks_on() + ax.tick_params(which='minor', direction='in', length=3, top=True, right=True) + ax.tick_params(direction='in', length=5, width=1.2, labelsize=18, top=True, right=True) + + xticks_span = np.linspace(x1.min(), x1.max(), 5) + + ax.set_xticks(xticks_span) + ax.set_xticklabels([f'{xticks_span[0]:.1f}', '', f'{xticks_span[2]:.1f}', '', f'{xticks_span[-1]:.1f}'], fontsize=25) + + yticks_span = np.linspace(y1.min(), y1.max(), 5) + + ax.set_yticks(yticks_span) + ax.set_yticklabels([f'{np.abs(yticks_span[0]):.1f}', '', '', '', f'{yticks_span[-1]:.4f}'], fontsize=25) + + plt.tight_layout() + plt.close(fig) + + # --- Print summary --- + print(f" Turn-on Voltage: {turnon_voltage:.3f} V") + + return turnon_voltage, fig # Turn-on calculated by thresholding + +def pinch_off_curve_ranges(x_data: np.array, + y_data: np.array, + threshold: float, + debug: bool = False, + plot_results: bool = True): + """Identify pinch-off and saturation voltage ranges for a sweep. + + This function normalizes the sign of the current, selects the scan + direction from the zero-voltage point, finds the pinch-off position + using slope detection, and then locates the saturation region. + """ + + # --- Data definitions --- + + x1 = np.array(x_data) + y1 = np.array(y_data) + + if y1[0] < 0: + y1 = -y1 + + # --- Check if we are pinch-offed --- + + if y1[0] < threshold: + raise ValueError("Current at the end of the sweep is above the pinch-off noisefloor, indicating the device may not be fully pinch-offed. Please check the data or adjust the threshold.") + + # --- Finding Pinch-off Voltage --- + + start_idx = int(np.argmin(np.abs(x1))) + if start_idx == 0: + step = 1 + elif start_idx == len(x1) - 1: + step = -1 + else: + left_abs = abs(x1[start_idx - 1]) + right_abs = abs(x1[start_idx + 1]) + step = 1 if right_abs >= left_abs else -1 + + scan_indices = np.arange(start_idx, len(x1), step) if step > 0 else np.arange(start_idx, -1, -1) + x_scan = x1[scan_indices] + y_scan = y1[scan_indices] + + # 1. Use the first 5% of data points (closest to 0V) to characterize the noise floor + baseline_window = max(5, int(0.05 * len(y_scan))) + baseline_data = y_scan[:baseline_window] + baseline_mean = np.mean(baseline_data) + baseline_std = np.std(baseline_data) + total_signal_range = np.max(y_scan) - np.min(y_scan) + + # If the start already exhibits heavy oscillations relative to the total range, + # it means the device turn-on is active right from the initial gate voltage. + if baseline_std > 0.02 * total_signal_range: + pinch_off_pos = 0 + else: + # Otherwise, find where it cleanly breaks away from a quiet noise floor + departure_threshold = baseline_mean + max(3.0 * baseline_std, 0.008 * total_signal_range) + pinch_off_pos = 0 + consecutive_points_needed = 3 + for i in range(len(y_scan) - consecutive_points_needed): + if all(y_scan[i + j] > departure_threshold for j in range(consecutive_points_needed)): + pinch_off_pos = i + break + + early_rise_threshold = max(3, int(0.05 * len(x_scan))) + if pinch_off_pos <= early_rise_threshold or pinch_off_pos >= len(x_scan) - 1: + idx_pinch_off = int(scan_indices[0]) + pinch_off_pos = 0 # Sync position + else: + idx_pinch_off = int(scan_indices[pinch_off_pos]) + + # Local peak adjustment fallback (kept from your original architecture) + if 0 < pinch_off_pos < len(y_scan) - 1: + if y_scan[pinch_off_pos] >= y_scan[pinch_off_pos - 1] and y_scan[pinch_off_pos] >= y_scan[pinch_off_pos + 1]: + look_ahead = min(len(y_scan), pinch_off_pos + max(3, int(0.05 * len(y_scan)))) + post_peak = y_scan[pinch_off_pos + 1:look_ahead] + if post_peak.size > 0: + min_rel = np.argmin(post_peak) + min_pos = pinch_off_pos + 1 + min_rel + if y_scan[pinch_off_pos] - y_scan[min_pos] > max(1e-6, 0.05 * abs(y_scan[pinch_off_pos])): + pinch_off_pos = min_pos + idx_pinch_off = int(scan_indices[pinch_off_pos]) + + pinch_off_voltage = x1[idx_pinch_off] + pinch_off_current = y1[idx_pinch_off] - p0_1 = [min(y1), max(y1), np.median(x1), 0.05] - params, popt, pcov = fit_to_function(x1, y1, sigmoid) + # --- Finding Saturation Voltage using Smooth Derivatives --- + + after_pinch = y_scan[pinch_off_pos:] + if len(after_pinch) < 5: + idx_sat = int(scan_indices[-1]) + sat_voltage = x1[idx_sat] + sat_current = y1[idx_sat] + sat_threshold = None + sat_plateau_start = None + else: + dx = np.abs(np.diff(x_scan)) + dx_mean = np.mean(dx) if len(dx) > 0 else 1.0 + + window_length = min(15, len(after_pinch) // 3) + if window_length % 2 == 0: + window_length = max(3, window_length - 1) + window_length = max(3, window_length) + + # Compute smooth 1st derivative + y_der = signal.savgol_filter(after_pinch, window_length=window_length, polyorder=2, deriv=1, delta=dx_mean) + + # Characterize terminal tail behavior + tail_size = min(15, len(y_der) // 4) + end_slopes = y_der[-tail_size:] + mean_end_slope = np.mean(end_slopes) + std_end_slope = np.std(end_slopes) + + # Use the 90th percentile of the derivative instead of the absolute maximum. + # This completely filters out the impact of an isolated giant climbing spike. + robust_max_slope = np.percentile(np.abs(y_der), 90) + slope_threshold = max(mean_end_slope + 3.0 * std_end_slope, 0.08 * robust_max_slope) + + # Trace backward from the end point + suffix_start = len(after_pinch) - 1 + while suffix_start > 0 and np.abs(y_der[suffix_start]) <= slope_threshold: + suffix_start -= 1 + + # Safeguard to prevent tracing back into the pinch-off region + if suffix_start <= 2: + suffix_start = len(after_pinch) - 1 + + sat_idx_scan = pinch_off_pos + suffix_start + idx_sat = int(scan_indices[sat_idx_scan]) + + sat_voltage = x1[idx_sat] + sat_current = y1[idx_sat] + sat_threshold = y1[idx_sat] + sat_plateau_start = sat_voltage + + # --- Fit sigmoids --- + params, popt, pcov = fit_to_function(x1, y1, sigmoid, print_results=False) # --- Extract key points --- A, B, V0, dV = popt - # Define the characteristic voltage range as (V0 ± √8 * dV) - - range_factor = np.sqrt(8) - - pinch_off = V0 - range_factor * dV - sat = V0 + range_factor * dV - # --- Plot data --- - - fig, ax = plt.subplots(figsize=(8,6)) - ax.plot(x1, y1, '-', color='C0', linewidth=2, label='I ($V_{B1}$)') - ax.legend(fontsize=24, frameon=False, loc='upper right') - # --- Double-sided arrows showing full range (swapped positions) --- + if plot_results: - # Define arrow y-positions (swap positions) + fig, ax = plt.subplots(figsize=(8,6)) + ax.plot(x1, y1, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') + ax.scatter(pinch_off_voltage, pinch_off_current, color='red', s=100, zorder=5, label='Pinch-off Point') + ax.scatter(sat_voltage, sat_current, color='green', s=100, zorder=5, label='Saturation Point') - y_arrow1 = ax.get_ylim()[1] + 0.05 # Device 1 arrow ABOVE - y_arrow2 = ax.get_ylim()[0] - 0.01 # Device 2 arrow BELOW + if debug == True: - # Device 1 arrow (now above) - - ax.annotate( - '', xy=(sat, y_arrow1), xytext=(pinch_off, y_arrow1), - arrowprops=dict(arrowstyle='<->', color='C0', lw=3.0, shrinkA=0, shrinkB=0), - annotation_clip=False - ) - ax.text((sat + pinch_off)/2, y_arrow1 - 0.05*(ax.get_ylim()[1]-ax.get_ylim()[0]), - s='', color='C0', ha='center', va='top', fontsize=20) + if sat_plateau_start is not None and sat_threshold is not None: + ax.axhline(sat_threshold, color='tab:green', linestyle='--', linewidth=1.25, alpha=0.85, label='Saturation Threshold') + ax.axvspan(sat_plateau_start, x1[scan_indices[-1]], color='tab:green', alpha=0.12) + ax.scatter(sat_plateau_start, sat_threshold, color='tab:green', marker='x', s=80, zorder=6, label='Saturation Start') - # --- Characteristic vertical lines extending exactly to the data points --- + ax.legend(fontsize=20, frameon=False, loc='upper left') - # Compute corresponding y-values from the *fitted sigmoid* (smooth, reliable) + # --- Double-sided arrows showing full range (swapped positions) --- - y_pinch1 = sigmoid(pinch_off, *popt) - y_sat1 = sigmoid(sat, *popt) + # Define arrow y-positions (swap positions) - for color, po, sat, label, y_arrow, direction, y_pinch, y_sat in [ - # Device 1 → arrow above, extend down to data - ('C0', pinch_off, sat, 'Device 1', y_arrow1, 'down', y_pinch1, y_sat1) - ]: - if direction == 'up': - # Extend upward from arrow to the y-values of the fitted curve - ax.vlines(po, ymin=y_arrow, ymax=y_pinch - 0.01, colors=color, linestyles='--', alpha=0.6) - ax.vlines(sat, ymin=y_arrow, ymax=y_sat - 0.025, colors=color, linestyles='--', alpha=0.6) - else: - # Extend downward from arrow to the y-values of the fitted curve - ax.vlines(po, ymin=y_pinch + 0.02, ymax=y_arrow, colors=color, linestyles='--', alpha=0.6) - ax.vlines(sat, ymin=y_sat - 0.015, ymax=y_arrow, colors=color, linestyles='--', alpha=0.6) + y_arrow1 = ax.get_ylim()[1] + 0.05 # Device 1 arrow ABOVE + # y_arrow2 = ax.get_ylim()[0] - 0.01 # Device 2 arrow BELOW + + # Device 1 arrow (now above) + + ax.annotate( + '', xy=(sat_voltage, y_arrow1), xytext=(pinch_off_voltage, y_arrow1), + arrowprops=dict(arrowstyle='<->', color='C0', lw=3.0, shrinkA=0, shrinkB=0), + annotation_clip=False + ) + ax.text((sat_voltage + pinch_off_voltage)/2, y_arrow1 - 0.05*(ax.get_ylim()[1]-ax.get_ylim()[0]), + s='', color='C0', ha='center', va='top', fontsize=20) + + # --- Characteristic vertical lines extending exactly to the data points --- - # --- Overlay fitted sigmoid curves --- + y_pinch1 = y1[np.where(x1 == pinch_off_voltage)][0] + y_sat1 = y1[np.where(x1 == sat_voltage)][0] - V_fit = np.linspace(x1.min(), x1.max(), 500) + for color, po, sat, label, y_arrow, direction, y_pinch, y_sat in [ + # Device 1 → arrow above, extend down to data + ('C0', pinch_off_voltage, sat_voltage, 'Device 1', y_arrow1, 'down', y_pinch1, y_sat1) + ]: + if direction == 'up': + # Extend upward from arrow to the y-values of the fitted curve + ax.vlines(po, ymin=y_arrow, ymax=y_pinch - 0.01, colors=color, linestyles='--', alpha=0.6) + ax.vlines(sat, ymin=y_arrow, ymax=y_sat - 0.025, colors=color, linestyles='--', alpha=0.6) + else: + # Extend downward from arrow to the y-values of the fitted curve + ax.vlines(po, ymin=y_pinch + 0.02, ymax=y_arrow, colors=color, linestyles='--', alpha=0.6) + ax.vlines(sat, ymin=y_sat - 0.015, ymax=y_arrow, colors=color, linestyles='--', alpha=0.6) - # Fitted curves for each device + # --- Labels and formatting --- - y_fit1 = sigmoid(V_fit, *popt) - y_fit2 = sigmoid(V_fit, *popt) + ax.set_xlabel(r'V$_{gate}$ (V)', fontsize=35) + ax.set_ylabel('I (nA)', fontsize=35) - # --- Labels and formatting --- + ax.minorticks_on() + ax.tick_params(which='minor', direction='in', length=3, top=True, right=True) + ax.tick_params(direction='in', length=5, width=1.2, labelsize=18, top=True, right=True) - ax.set_xlabel(r'V$_{B1}$, V$_{B2}$ (V)', fontsize=45) - ax.set_ylabel('I (nA)', fontsize=55) + xticks_span = np.linspace(x1.min(), x1.max(), 5) - ax.minorticks_on() - ax.tick_params(which='minor', direction='in', length=3, top=True, right=True) - ax.tick_params(direction='in', length=5, width=1.2, labelsize=18, top=True, right=True) + ax.set_xticks(xticks_span) + ax.set_xticklabels([f'{xticks_span[0]:.2f}', '', f'{xticks_span[2]:.2f}', '', f'{xticks_span[-1]:.2f}'], fontsize=25) - ax.set_xticks([-2.5, -2.0, -1.5, -1.0, -0.5]) - ax.set_xticklabels(['-2.5', '', '', '', '-0.5'], fontsize=45) + yticks_span = np.linspace(y1.min(), y1.max(), 5) - ax.set_yticks([0.0, 0.4, 0.8, 1.2]) - ax.set_yticklabels(['0.0', '', '', '1.2'], fontsize=45) + ax.set_yticks(yticks_span) + ax.set_yticklabels([f'{yticks_span[0]:.2f}', '', '', '', f'{yticks_span[-1]:.3f}'], fontsize=25) - # Extend y-limits slightly to make space for arrows + # Extend y-limits slightly to make space for arrows - ax.set_ylim(-0.1, ax.get_ylim()[1]) + ax.set_xlim(ax.get_xlim()[0], ax.get_xlim()[1]) + ax.set_ylim(ax.get_ylim()[0], ax.get_ylim()[1]) - plt.tight_layout() - plt.show() + plt.tight_layout() + plt.close(fig) # --- Print summary --- - print(f" Saturation Voltage: {sat:.3f} V") + print(f" Saturation Voltage: {sat_voltage:.3f} V") print(f" Midpoint Voltage: {V0:.3f} V") - print(f" Pinch-off Voltage: {pinch_off:.3f} V\n") + print(f" Pinch-off Voltage: {pinch_off_voltage:.3f} V\n") + + voltage_window = (pinch_off_voltage, sat_voltage) - pass + return voltage_window, fig def extract_max_conductance_points(self, x_data, y_data): + """Analyze current data to identify the largest conductance features. + + This function plots the current and its derivative, then highlights + the most extreme conductance peaks and valleys. + """ x1 = np.array(x_data) y1 = np.array(y_data) @@ -284,22 +521,38 @@ def extract_max_conductance_points(self, x_data, y_data): plt.subplots_adjust(hspace=0.40) plt.show() -def extract_bias_point( - lb_data: np.array, - rb_data: np.array, - current_data: np.array, - minAngleDeg: float = -55, - maxAngleDeg: float = -35, - minLineLength: int = 50, - maxLineGap: int = 250, - debug: bool = False, - plot_results: bool = True) -> list[tuple]: +def extract_working_point(lb_data: np.array, + rb_data: np.array, + current_data: np.array, + gates: list[str], + DotTuning: str, + barrier_pinch_offs: list[float], + minAngleDeg: float = -60, + maxAngleDeg: float = -30, + minLineLength: int = 60, + maxLineGap: int = 200, + debug: bool = False, + plot_results: bool = True) -> list[tuple]: + """Find working-point lines in a 2D barrier sweep image. + + This function converts raw barrier voltage and current data into an image, + applies ridge detection and Hough transform filtering, and returns the + extracted working-point lines that correspond to relevant device ridges. + """ # We start by ensuring our inputs are numpy arrays lb_data = np.array(lb_data) rb_data = np.array(rb_data) current_data = np.array(current_data) + device_type = 'hole' + + if current_data[0] < 0: + current_data = -current_data + + if np.average(lb_data) > 0 and np.average(rb_data) > 0: + current_data = np.flip(current_data, axis=None) + device_type = 'electron' # Now, we reshape the data into an array @@ -344,41 +597,156 @@ def extract_bias_point( ridge = sato(band_passed, sigmas=[1, 2, 3], black_ridges=False) ridge_norm = (ridge - ridge.min()) / (np.ptp(ridge) + epsilon) - ridge_filtered = ridge_norm > 0.20 + ridge_filtered = ridge_norm > 0.18 - # Now, we limit our analysis to the bottom left-quadrant + # Close very small gaps in the ridge image so the Hough transform sees longer continuous lines. + ridge_filtered = cv2.morphologyEx( + ridge_filtered.astype(np.uint8), + cv2.MORPH_CLOSE, + np.ones((3, 3), np.uint8) + ).astype(bool) - ridge_masked = np.zeros_like(ridge_filtered) - ridge_masked[:ny // 2, :nx // 2] = ridge_filtered[:ny // 2, :nx // 2] + # Now, we limit our analysis to the red zone based on device type + # Convert barrier_pinch_offs to pixel coordinates + + # pixel index arrays (needed for interpolation) + x_index_arr = np.arange(nx) + y_index_arr = np.arange(ny) - # From these edges, we detect lines using a probabilistic hough transform + # enlarge region by adding 0.1 V to pinch-off values + x_idx_mid = np.interp(barrier_pinch_offs[0] + 0.05, lb_voltages, x_index_arr) + y_idx_mid = np.interp(barrier_pinch_offs[1] + 0.05, rb_voltages, y_index_arr) + x_idx_mid = int(np.clip(x_idx_mid, 0, nx - 1)) + y_idx_mid = int(np.clip(y_idx_mid, 0, ny - 1)) + + ridge_masked = np.zeros_like(ridge_filtered) + + if device_type == 'electron': + # Electron: analyze bottom-left, top-left, bottom-right (exclude top-right) + ridge_masked[:y_idx_mid, :x_idx_mid] = ridge_filtered[:y_idx_mid, :x_idx_mid] + ridge_masked[y_idx_mid:, :x_idx_mid] = ridge_filtered[y_idx_mid:, :x_idx_mid] + ridge_masked[:y_idx_mid, x_idx_mid:] = ridge_filtered[:y_idx_mid, x_idx_mid:] + else: # hole + # Hole: analyze top-right, top-left, bottom-right (exclude bottom-left) + ridge_masked[y_idx_mid:, x_idx_mid:] = ridge_filtered[y_idx_mid:, x_idx_mid:] + ridge_masked[y_idx_mid:, :x_idx_mid] = ridge_filtered[y_idx_mid:, :x_idx_mid] + ridge_masked[:y_idx_mid, x_idx_mid:] = ridge_filtered[:y_idx_mid, x_idx_mid:] + + # From these edges, we detect lines using a probabilistic hough transform. + # Use a slightly lower threshold and tune the minimum required segment length so long bottom-left lines are prioritized. + hough_threshold = max(5, int(0.02 * max(nx, ny))) + hough_length = max(12, int(minLineLength * 0.15)) + hough_gap = max(1, int(maxLineGap * 0.03)) lines = transform.probabilistic_hough_line( ridge_masked, - threshold=15, - line_length=max(2, int(minLineLength * 0.1)), - line_gap=max(1, int(maxLineGap * 0.02)) + threshold=hough_threshold, + line_length=hough_length, + line_gap=hough_gap ) - if not lines: - return [] + roi = None + roi_offset = (0, 0) + if device_type == 'electron' and x_idx_mid > 5 and y_idx_mid > 5: + roi = ridge_masked[:y_idx_mid, :x_idx_mid] + roi_offset = (0, 0) + elif device_type != 'electron' and x_idx_mid < nx - 5 and y_idx_mid < ny - 5: + roi = ridge_masked[y_idx_mid:, x_idx_mid:] + roi_offset = (x_idx_mid, y_idx_mid) + + if roi is not None and roi.size > 0: + extra_lines = transform.probabilistic_hough_line( + roi, + threshold=max(5, hough_threshold - 3), + line_length=max(8, int(minLineLength * 0.12)), + line_gap=max(1, int(maxLineGap * 0.05)) + ) + for p0, p1 in extra_lines: + lines.append(( + (p0[0] + roi_offset[0], p0[1] + roi_offset[1]), + (p1[0] + roi_offset[0], p1[1] + roi_offset[1]) + )) + + # if not lines: + # return [] # Now, we filter for lines within a certain angle range - filtered_lines = [] + line_candidates = [] + min_length = max(0.10 * max(nx, ny), 15) for p0, p1 in lines: dx, dy = p1[0] - p0[0], p1[1] - p0[1] angle = np.degrees(np.arctan2(dy, dx)) - if minAngleDeg <= angle <= maxAngleDeg: - filtered_lines.append((*p0, *p1)) + length = np.hypot(dx, dy) + if minAngleDeg <= angle <= maxAngleDeg and length >= min_length: + midx = 0.5 * (p0[0] + p1[0]) + midy = 0.5 * (p0[1] + p1[1]) + if device_type == 'electron': + score = length - 0.35 * (midx + midy) + else: + score = length - 0.35 * ((nx - midx) + (ny - midy)) + line_candidates.append((score, (*p0, *p1))) + + line_candidates.sort(key=lambda item: -item[0]) + filtered_lines = [entry[1] for entry in line_candidates] if not filtered_lines: - return [] + # Relax angle range slightly if no good long line was found. + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + length = np.hypot(dx, dy) + if (minAngleDeg - 10) <= angle <= (maxAngleDeg + 10) and length >= min_length: + midx = 0.5 * (p0[0] + p1[0]) + midy = 0.5 * (p0[1] + p1[1]) + if device_type == 'electron': + score = length - 0.35 * (midx + midy) + else: + score = length - 0.35 * ((nx - midx) + (ny - midy)) + line_candidates.append((score, (*p0, *p1))) + line_candidates.sort(key=lambda item: -item[0]) + filtered_lines = [entry[1] for entry in line_candidates] + + if not filtered_lines and roi is not None and roi.size > 0: + for alt_img in [band_passed, G_uint, ridge_norm]: + alt_roi = alt_img[:y_idx_mid, :x_idx_mid] if device_type == 'electron' else alt_img[y_idx_mid:, x_idx_mid:] + extra_lines = transform.probabilistic_hough_line( + alt_roi, + threshold=max(4, hough_threshold - 4), + line_length=max(8, int(minLineLength * 0.12)), + line_gap=max(1, int(maxLineGap * 0.05)) + ) + for p0, p1 in extra_lines: + lines.append(( + (p0[0] + roi_offset[0], p0[1] + roi_offset[1]), + (p1[0] + roi_offset[0], p1[1] + roi_offset[1]) + )) + + line_candidates = [] + for p0, p1 in lines: + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + angle = np.degrees(np.arctan2(dy, dx)) + length = np.hypot(dx, dy) + if (minAngleDeg - 10) <= angle <= (maxAngleDeg + 10) and length >= min_length: + midx = 0.5 * (p0[0] + p1[0]) + midy = 0.5 * (p0[1] + p1[1]) + if device_type == 'electron': + score = length - 0.35 * (midx + midy) + else: + score = length - 0.35 * ((nx - midx) + (ny - midy)) + line_candidates.append((score, (*p0, *p1))) + line_candidates.sort(key=lambda item: -item[0]) + filtered_lines = [entry[1] for entry in line_candidates] + + # if not filtered_lines: + # return [] # Previously, we limited analysis to the bottom left quadrant, here we're defining the voltage range for that quadrant + # Using the pinch-off voltages from barrier_pinch_offs parameter - lb_mid_volt = 0.5 * (lb_data.min() + lb_data.max()) - rb_mid_volt = 0.5 * (rb_data.min() + rb_data.max()) + # enlarge region by adding 0.1 V to pinch-off values + lb_mid_volt = barrier_pinch_offs[0] + 0.05 # First value: x-axis (left/bottom gate) + rb_mid_volt = barrier_pinch_offs[1] + 0.05 # Second value: y-axis (right/bottom gate) perp_candidates = [] perp_traces_for_plot = [] @@ -387,8 +755,7 @@ def extract_bias_point( perp_samples = 400 smooth_sigma = 2.0 - x_index_arr = np.arange(nx) - y_index_arr = np.arange(ny) + # Now, for each filtered line, we define a line perpendicular to it, then find the peaks in current along them @@ -443,9 +810,9 @@ def extract_bias_point( } # find local maxima of current - noise_sigma = 1.4826 * np.median(np.abs(trace_smooth - np.median(trace_smooth))) + noise_sigma = 1.4826 * np.median(np.abs(conductance - np.median(conductance))) prominence_thresh = 4.0 * noise_sigma - peaks, _ = signal.find_peaks(trace_smooth, + peaks, _ = signal.find_peaks(conductance, prominence=prominence_thresh, distance=15) @@ -460,8 +827,11 @@ def extract_bias_point( vx = np.interp(px, x_index_arr, lb_voltages) vy = np.interp(py, y_index_arr, rb_voltages) - # bottom-left quadrant restriction - valid = (vx < lb_mid_volt) & (vy < rb_mid_volt) + # restrict to red zone based on device type + if device_type == 'electron': + valid = (vx < lb_mid_volt) | (vy < rb_mid_volt) + else: # hole + valid = (vx > lb_mid_volt) | (vy > rb_mid_volt) peak_idx = peak_idx[valid] px = px[valid] py = py[valid] @@ -493,8 +863,8 @@ def extract_bias_point( perp_traces_for_plot.append(trace_info) - if not perp_candidates: - return [] + # if not perp_candidates: + # return [] # ---------- Selecting Final Bias Points ---------- @@ -525,6 +895,30 @@ def extract_bias_point( for (_, vx, vy, _, _, _) in top_candidates ] + # Compute selected working points. For Triple Dot, shift each point 0.1 V in the + # opposite yellow trace direction instead of perpendicular to it. + selected_working_points = [] + traces_by_id = {tr["trace_id"]: tr for tr in perp_traces_for_plot} + for cand in top_candidates: + _, vx_c, vy_c, px_c, py_c, tid = cand + tr = traces_by_id.get(tid, None) + if tr is not None and str(DotTuning).strip().lower() == 'triple dot': + try: + vx_trace = np.interp(tr["px"], x_index_arr, lb_voltages) + vy_trace = np.interp(tr["py"], y_index_arr, rb_voltages) + direction = np.array([vx_trace[-1] - vx_trace[0], vy_trace[-1] - vy_trace[0]]) + norm_dir = np.hypot(direction[0], direction[1]) + if norm_dir > 0: + unit_dir = direction / norm_dir + shift_vec = -unit_dir * 0.1 + selected_working_points.append((float(round(vx_c + shift_vec[0], 3)), float(round(vy_c + shift_vec[1], 3)))) + else: + selected_working_points.append((round(vx_c, 3), round(vy_c, 3))) + except Exception: + selected_working_points.append((round(vx_c, 3), round(vy_c, 3))) + else: + selected_working_points.append((round(vx_c, 3), round(vy_c, 3))) + perp_traces_for_plot = [ tr for tr in perp_traces_for_plot if tr["trace_id"] in selected_trace_ids @@ -536,7 +930,10 @@ def extract_bias_point( vx = np.interp(tr["px"], x_index_arr, lb_voltages) vy = np.interp(tr["py"], y_index_arr, rb_voltages) - in_quad = (vx < lb_mid_volt) & (vy < rb_mid_volt) + if device_type == 'electron': + in_quad = (vx < lb_mid_volt) | (vy < rb_mid_volt) + else: # hole + in_quad = (vx > lb_mid_volt) | (vy > rb_mid_volt) if not np.any(in_quad): continue @@ -547,10 +944,15 @@ def extract_bias_point( peak_idx = tr.get("peak_idx", None) chosen_block = None if peak_idx is not None: + best_count = -1 + best_block = None for b in blocks: - if np.intersect1d(peak_idx, b).size > 0: - chosen_block = b - break + count = np.intersect1d(peak_idx, b).size + if count > best_count: + best_count = count + best_block = b + if best_count > 0: + chosen_block = best_block if chosen_block is None: chosen_block = blocks[0] @@ -585,10 +987,10 @@ def round_to_step(x, step): return step * np.round(x / step) x0, x1 = round_to_step(lb_data.min(), step), round_to_step(lb_data.max(), step) y0, y1 = round_to_step(rb_data.min(), step), round_to_step(rb_data.max(), step) - ax.set_xticks([0.65, 0.85]) - ax.set_yticks([0.60, 0.70]) - ax.set_xticklabels(["0.65", "0.85"], fontsize=40) - ax.set_yticklabels(["0.60", "0.70"], fontsize=40) + ax.set_xticks([lb_data.min(), lb_data.max()]) + ax.set_yticks([rb_data.min(), rb_data.max()]) + ax.set_xticklabels([str(lb_data.min()), str(lb_data.max())], fontsize=30) + ax.set_yticklabels([str(rb_data.min()), str(rb_data.max())], fontsize=30) ax.tick_params( which="major", @@ -613,10 +1015,10 @@ def round_to_step(x, step): return step * np.round(x / step) top=True, right=True ) - + # Axis labels - ax.set_xlabel(r'V$_{B2}$ (V)', fontsize=45, labelpad = -35) - ax.set_ylabel(r'V$_{B1}$ (V)', fontsize=45) + ax.set_xlabel(rf'V$_{{{gates[0]}}}$ (V)', fontsize=35, labelpad = -25) + ax.set_ylabel(rf'V$_{{{gates[1]}}}$ (V)', fontsize=35) ax.yaxis.set_label_coords(-0.025, 0.40) @@ -635,9 +1037,10 @@ def round_to_step(x, step): return step * np.round(x / step) cbar.set_label("I (nA)", fontsize=35, labelpad=10) cbar.ax.xaxis.set_ticks_position("bottom") cbar.ax.xaxis.set_label_position("top") - cbar.set_ticks([0.0, 0.25, 0.50, 0.75, 1.0]) - cbar.set_ticklabels(['0', '', '', '', '1']) - cbar.ax.tick_params(labelsize=30, direction="in", length=6) + cbar_ticks = np.linspace(0, current_data.max(), 5) + cbar.set_ticks(cbar_ticks) + cbar.set_ticklabels([f'{tick:.2f}' for tick in cbar_ticks]) + cbar.ax.tick_params(labelsize=25, direction="in", length=6) cbar.ax.minorticks_on() @@ -651,39 +1054,123 @@ def round_to_step(x, step): return step * np.round(x / step) width=1.0 ) - # Block Boundary + # Block Boundary and shaded region based on device type + + if device_type == 'electron': + # Electron: exclude top-right quadrant + # Top side: horizontal line from center to right edge + ax.plot( + [lb_mid_volt, lb_data.max()], # x: center → right + [rb_mid_volt, rb_mid_volt], # y constant at middle + linestyle='--', + color='red', + linewidth=1.2, + alpha=0.9 + ) + + # Right side: vertical line from center to top edge + ax.plot( + [lb_mid_volt, lb_mid_volt], # x constant at center + [rb_mid_volt, rb_data.max()], # y: middle → top + linestyle='--', + color='red', + linewidth=1.2, + alpha=0.9 + ) - # Top side: horizontal line from left-mid to right-mid - ax.plot( - [lb_mid_volt, lb_data.min()], # x: left → right - [rb_mid_volt, rb_mid_volt], # y constant at top - linestyle='--', - color='red', - linewidth=1.2, - alpha=0.9 + # Bottom-left quadrant + rect1 = Rectangle( + (lb_data.min(), rb_data.min()), # bottom-left corner + lb_mid_volt - lb_data.min(), # width + rb_mid_volt - rb_data.min(), # height + facecolor='red', + alpha=0.2, + edgecolor=None, + zorder=2 ) - - # Right side: vertical line from bottom-mid to top-mid - ax.plot( - [lb_mid_volt, lb_mid_volt], # x constant at right - [rb_data.min(), rb_mid_volt], # y: bottom → top - linestyle='--', - color='red', - linewidth=1.2, - alpha=0.9 + ax.add_patch(rect1) + + # Top-left quadrant + rect2 = Rectangle( + (lb_data.min(), rb_mid_volt), # top-left corner + lb_mid_volt - lb_data.min(), # width + rb_data.max() - rb_mid_volt, # height + facecolor='red', + alpha=0.2, + edgecolor=None, + zorder=2 + ) + ax.add_patch(rect2) + + # Bottom-right quadrant + rect3 = Rectangle( + (lb_mid_volt, rb_data.min()), # bottom-right corner + lb_data.max() - lb_mid_volt, # width + rb_mid_volt - rb_data.min(), # height + facecolor='red', + alpha=0.2, + edgecolor=None, + zorder=2 ) + ax.add_patch(rect3) + + else: # hole + # Hole: exclude bottom-left quadrant + # Bottom side: horizontal line from left edge to center + ax.plot( + [lb_data.min(), lb_mid_volt], # x: left → center + [rb_mid_volt, rb_mid_volt], # y constant at middle + linestyle='--', + color='red', + linewidth=1.2, + alpha=0.9 + ) - rect = Rectangle( - (lb_data.min(), rb_data.min()), # bottom-left corner - lb_mid_volt - lb_data.min(), # width - rb_mid_volt - rb_data.min(), # height - facecolor='red', - alpha=0.2, # set opacity here (1.0 = fully opaque) - edgecolor=None, - zorder=2 - ) + # Left side: vertical line from bottom edge to center + ax.plot( + [lb_mid_volt, lb_mid_volt], # x constant at center + [rb_data.min(), rb_mid_volt], # y: bottom → middle + linestyle='--', + color='red', + linewidth=1.2, + alpha=0.9 + ) - ax.add_patch(rect) + # Top-right quadrant + rect1 = Rectangle( + (lb_mid_volt, rb_mid_volt), # top-right corner + lb_data.max() - lb_mid_volt, # width + rb_data.max() - rb_mid_volt, # height + facecolor='red', + alpha=0.2, + edgecolor=None, + zorder=2 + ) + ax.add_patch(rect1) + + # Top-left quadrant + rect2 = Rectangle( + (lb_data.min(), rb_mid_volt), # top-left corner + lb_mid_volt - lb_data.min(), # width + rb_data.max() - rb_mid_volt, # height + facecolor='red', + alpha=0.2, + edgecolor=None, + zorder=2 + ) + ax.add_patch(rect2) + + # Bottom-right quadrant + rect3 = Rectangle( + (lb_mid_volt, rb_data.min()), # bottom-right corner + lb_data.max() - lb_mid_volt, # width + rb_mid_volt - rb_data.min(), # height + facecolor='red', + alpha=0.2, + edgecolor=None, + zorder=2 + ) + ax.add_patch(rect3) # Hough lines for x1, y1, x2, y2 in filtered_lines: @@ -694,9 +1181,13 @@ def round_to_step(x, step): return step * np.round(x / step) v2y = np.interp(y2, y_index_arr, rb_voltages) # Uncomment below to see the detected Hough lines - ax.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) + # ax.plot([v1x, v2x], [v1y, v2y], c='black', lw=1.2) # Perpendicular traces and peaks + dot_tuning_shift = 0.1 if str(DotTuning).strip().lower() == 'triple dot' else 0.0 + + shifted_points = [] + for tr in perp_traces_for_plot: idx = tr.get("chosen_block", None) if idx is None or len(idx) == 0: continue @@ -704,12 +1195,73 @@ def round_to_step(x, step): return step * np.round(x / step) vy = np.interp(tr["py"], np.arange(ny), rb_voltages) ax.plot(vx[idx], vy[idx], c='yellow', lw=1.5, alpha=0.9) valid_peaks = np.intersect1d(tr.get("peak_idx", []), idx) - ax.scatter(vx[valid_peaks], vy[valid_peaks], s=150, c='white', - marker='*', edgecolors='black', zorder=10) + + if valid_peaks.size > 0: + # Plot each peak and shift it along the perpendicular normal towards origin + try: + for p in np.atleast_1d(valid_peaks): + i = int(p) + vx_p = float(vx[i]) + vy_p = float(vy[i]) + + # compute a local tangent along the yellow trace and shift along it + if 1 <= i < (len(vx) - 1): + ddx = float(vx[i + 1]) - float(vx[i - 1]) + ddy = float(vy[i + 1]) - float(vy[i - 1]) + else: + # fallback to using the chosen block endpoints + ddx = float(vx[idx][-1]) - float(vx[idx][0]) + ddy = float(vy[idx][-1]) - float(vy[idx][0]) + + norm_dir = np.hypot(ddx, ddy) + if norm_dir > 0: + shift_dir = np.array([ddx / norm_dir, ddy / norm_dir]) + shift_vec = -shift_dir * dot_tuning_shift + else: + shift_dir = np.array([0.0, 1.0]) + shift_vec = shift_dir * dot_tuning_shift + + sx = vx_p + float(shift_vec[0]) + sy = vy_p + float(shift_vec[1]) + + # Debug: report computed shift direction and shift vector + if debug: + try: + print(f"DEBUG_SHIFT peak={i} vx={vx_p:.6f} vy={vy_p:.6f} dir=({shift_dir[0]:.6f},{shift_dir[1]:.6f}) shift_vec=({shift_vec[0]:.6f},{shift_vec[1]:.6f})") + # draw a cyan debug line showing the shift direction + ax.plot([vx_p, sx], [vy_p, sy], c='cyan', lw=1.5, alpha=0.9, zorder=9) + except Exception: + pass + + # shifted star (exactly dot_tuning_shift along the yellow perp) + ax.scatter(sx, sy, s=200, c='white', marker='*', edgecolors='black', zorder=10) + shifted_points.append((sx, sy)) + # hollow red circle at original peak position + ax.scatter(vx_p, vy_p, s=80, c='none', edgecolors='red', linewidths=1.5, zorder=11) + # arrow from original to shifted star + ax.annotate('', xy=(sx, sy), xytext=(vx_p, vy_p), + arrowprops=dict(arrowstyle='->', color='black', lw=1.0), zorder=12) + except Exception: + pass ax.set_box_aspect(0.775) - plt.show() + # If any shifted points lie outside current axis limits, expand limits slightly + # if len(shifted_points) > 0: + # sx_vals = [p[0] for p in shifted_points] + # sy_vals = [p[1] for p in shifted_points] + # xmin, xmax = ax.get_xlim() + # ymin, ymax = ax.get_ylim() + # pad_x = 0.02 * (xmax - xmin) if (xmax - xmin) != 0 else 0.01 + # pad_y = 0.02 * (ymax - ymin) if (ymax - ymin) != 0 else 0.01 + # new_xmin = min(xmin, min(sx_vals) - pad_x) + # new_xmax = max(xmax, max(sx_vals) + pad_x) + # new_ymin = min(ymin, min(sy_vals) - pad_y) + # new_ymax = max(ymax, max(sy_vals) + pad_y) + # ax.set_xlim(new_xmin, new_xmax) + # ax.set_ylim(new_ymin, new_ymax) + + plt.close(fig) # These are 1D perpendicular trace plots @@ -733,17 +1285,7 @@ def round_to_step(x, step): return step * np.round(x / step) f"Perpendicular trace {tr['trace_id']}" ) - # Mark current peaks - if len(peak_idx) > 0: - axs[0].scatter( - s[peak_idx], - I[peak_idx], - c="red", - s=40, - zorder=5, - label="Current peaks" - ) # Shade chosen block (if present) @@ -764,13 +1306,23 @@ def round_to_step(x, step): return step * np.round(x / step) axs[1].set_xlabel("Arc length s (pixels)") axs[1].set_ylabel("dI/ds") + # Mark current peaks + + if len(peak_idx) > 0: + axs[1].scatter( + s[peak_idx], + dIds[peak_idx], + c="red", + s=40, + zorder=5, + label="Current peaks" + ) + plt.tight_layout() plt.show() - # ---------- Debugging Code ---------- - if debug: # We first plot the original current data @@ -782,8 +1334,8 @@ def round_to_step(x, step): return step * np.round(x / step) aspect='auto', cmap='coolwarm' ) - plt.xlabel(r'V$_{B2}$ (V)', fontsize=45) - plt.ylabel(r'V$_{B1}$ (V)', fontsize=45) + plt.xlabel(rf'V$_{{{gates[0]}}}$ (V)', fontsize=45) + plt.ylabel(rf'V$_{{{gates[1]}}}$ (V)', fontsize=45) plt.title("Original Current Data") plt.show() @@ -1293,12 +1845,21 @@ def round_to_step(x, step): return step * np.round(x / step) plt.title("Hough Transform Lines from Filtered Ridges") plt.show() - return perp_bias_points, perp_traces_for_plot + if DotTuning == 'Triple Dot': + return shifted_points, perp_traces_for_plot, fig + elif DotTuning == 'SET': + return perp_bias_points, perp_traces_for_plot, fig def extract_lever_arms(data: pd.DataFrame, plot_process: bool = False) -> dict: + """Estimate lever arms from a 2D transconductance map. + + This function pivots the input dataframe to a grid, computes the + gradient in the current data, applies filtering, and optionally plots + the intermediate transconductance results. + """ - # Load in data and seperate + # Load in data and separate X_name, Y_name, Z_name = data.columns Xdata, Ydata = np.unique(data[X_name]), np.unique(data[Y_name]) diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index e69de29..a4b3873 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -0,0 +1,212 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8866fe3d", + "metadata": {}, + "source": [ + "# data_analysis.py file Test with Data from Intel Device" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "776c6e9a", + "metadata": {}, + "outputs": [], + "source": [ + "import data_analysis as da\n", + "import pandas as pd\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S FC\\\\\"" + ] + }, + { + "cell_type": "markdown", + "id": "b05a8095", + "metadata": {}, + "source": [ + "### Turn-on" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14c14b02", + "metadata": {}, + "outputs": [], + "source": [ + "file_path_turnon = file_path + \"Turnon.csv\"\n", + "df = pd.read_csv(file_path_turnon)\n", + "Xdata = df[\"P0 (V)\"].to_numpy()\n", + "TDdata = df[\"Triple Dot Voltage (V)\"].to_numpy()\n", + "SETData = df[\"SET Voltage (V)\"].to_numpy()\n", + "\n", + "print(\"Triple Dot Turn on:\")\n", + "TD_turnon = da.extract_turn_on_voltage(Xdata, TDdata)\n", + "\n", + "# print(\"SET Turn on:\")\n", + "# SET_turnon = da.extract_turn_on_voltage(Xdata, SETData)" + ] + }, + { + "cell_type": "markdown", + "id": "4aa823e8", + "metadata": {}, + "source": [ + "### Pinch-Off Curves First Cooldown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52e88332", + "metadata": {}, + "outputs": [], + "source": [ + "files = [\"B0PO.csv\", \"B1PO.csv\", \"B2PO.csv\", \"B3PO.csv\", \"P0PO.csv\", \"P1PO.csv\", \"P2PO.csv\"]\n", + "for file in files:\n", + " file_path_po = file_path + file\n", + " df = pd.read_csv(file_path_po)\n", + " name = df.columns[1]\n", + " Xdata = df[name].to_numpy()\n", + " TDdata = df[\"Triple Dot Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "\n", + " print(\"Gate pinch-off:\")\n", + " voltage_window = da.pinch_off_curve_ranges(Xdata, TDdata)\n", + "\n", + "files = [\"B20PO.csv\", \"B21PO.csv\", \"P20PO.csv\"]\n", + "for file in files:\n", + " file_path_po = file_path + file\n", + " df = pd.read_csv(file_path_po)\n", + " name = df.columns[1]\n", + " Xdata = df[name].to_numpy()\n", + " SETdata = df[\"SET Voltage (V)\"].to_numpy()*1e-8*1e9 #nA\n", + "\n", + " print(\"Gate pinch-off:\")\n", + " voltage_window = da.pinch_off_curve_ranges(Xdata, SETdata)" + ] + }, + { + "cell_type": "markdown", + "id": "4cd72ae6", + "metadata": {}, + "source": [ + "### Resweeping Barrier Gates" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a16a36d", + "metadata": {}, + "outputs": [], + "source": [ + "files = [\"B20S_2.csv\", \"B21S_2.csv\"]\n", + "barrier_pinch_offs = []\n", + "for file in files:\n", + " file_path_bg = file_path + file\n", + " df = pd.read_csv(file_path_bg)\n", + " name = df.columns[1]\n", + " Xdata = df[name].to_numpy()\n", + " TDdata = df[\"Triple Dot Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + " nf = np.average(TDdata[-20:])\n", + "\n", + " print(\"Barrier gate Resweep:\")\n", + " voltage_window, pinch_off_fig = da.pinch_off_curve_ranges(Xdata, TDdata, nf)\n", + " barrier_pinch_offs.append(voltage_window[0])\n", + " display(pinch_off_fig)\n", + " \n", + "print(\"Barrier pinch-off voltages:\", barrier_pinch_offs)" + ] + }, + { + "cell_type": "markdown", + "id": "b12ca363", + "metadata": {}, + "source": [ + "### Pinch-off for Second Cooldown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d772f3d4", + "metadata": {}, + "outputs": [], + "source": [ + "files = [\"AC0PO.csv\", \"AC1PO.csv\", \"B0PO_2.csv\", \"B1PO_2.csv\", \"B2PO_2.csv\", \"B3PO_2.csv\", \"P0PO_2.csv\", \"P1PO_2.csv\", \"P2PO_2.csv\"]\n", + "for file in files:\n", + " file_path_po = file_path + file\n", + " df = pd.read_csv(file_path_po)\n", + " name = df.columns[1]\n", + " Xdata = df[name].to_numpy()\n", + " TDdata = df[\"Triple Dot Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", + "\n", + " print(\"Gate pinch-off:\")\n", + " voltage_window = da.pinch_off_curve_ranges(Xdata, TDdata)\n", + "\n", + "files = [\"AC2PO.csv\", \"AC3PO.csv\", \"B20PO_2.csv\", \"B21PO_2.csv\", \"P20PO_2.csv\"]\n", + "for file in files:\n", + " file_path_po = file_path + file\n", + " df = pd.read_csv(file_path_po)\n", + " name = df.columns[1]\n", + " Xdata = df[name].to_numpy()\n", + " SETdata = df[\"SET Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", + "\n", + " print(\"Gate pinch-off:\")\n", + " voltage_window = da.pinch_off_curve_ranges(Xdata, SETdata)" + ] + }, + { + "cell_type": "markdown", + "id": "90827718", + "metadata": {}, + "source": [ + "### Barrier-Barrier Scan for First Cooldown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f2facdf", + "metadata": {}, + "outputs": [], + "source": [ + "file_path_bbs = file_path + \"B2B3BBS.csv\"\n", + "df = pd.read_csv(file_path_bbs)\n", + "lbdata = df[\"B2 (V)\"].to_numpy()\n", + "rbdata = df[\"B3 (V)\"].to_numpy()\n", + "TDdata = df[\"Triple Dot Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "SETdata = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "\n", + "print(\"B2-B3 Sweep:\")\n", + "working_points, perp_lines, fig = da.extract_working_point(lbdata, rbdata, TDdata, [\"B2\", \"B3\"], 'Triple Dot', barrier_pinch_offs=barrier_pinch_offs, debug=False, plot_results=True)\n", + "display(fig)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 59c0df77d7a12bb2d3ef6b07748652f9d918ee81 Mon Sep 17 00:00:00 2001 From: Drv5MC <116960417+Drv5MC@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:02:58 -0400 Subject: [PATCH 26/36] Barrier-Barrier Scan code improved Made extract_working_point code return more useful information --- src/data_analysis.py | 291 +++++++++++++++++------------------ src/data_analysis_test.ipynb | 8 +- 2 files changed, 148 insertions(+), 151 deletions(-) diff --git a/src/data_analysis.py b/src/data_analysis.py index add79a7..7f7f516 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -321,6 +321,8 @@ def pinch_off_curve_ranges(x_data: np.array, A, B, V0, dV = popt + y_fit = sigmoid(x1, *popt) + # --- Plot data --- if plot_results: @@ -329,6 +331,7 @@ def pinch_off_curve_ranges(x_data: np.array, ax.plot(x1, y1, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') ax.scatter(pinch_off_voltage, pinch_off_current, color='red', s=100, zorder=5, label='Pinch-off Point') ax.scatter(sat_voltage, sat_current, color='green', s=100, zorder=5, label='Saturation Point') + ax.plot(x1, y_fit, '--', color='red', linewidth=2, label='Fitted Sigmoid') if debug == True: @@ -545,8 +548,30 @@ def extract_working_point(lb_data: np.array, lb_data = np.array(lb_data) rb_data = np.array(rb_data) current_data = np.array(current_data) + barrier_pinch_offs = np.array(barrier_pinch_offs) device_type = 'hole' + # 2. Establish uniform coordinate grids + # (Assumes original data represents a regular mesh grid) + ux = np.unique(lb_data) + uy = np.unique(rb_data) + + # Define your clipping thresholds here (adjust values as needed) + lb_min, lb_max = barrier_pinch_offs[0], ux.max() # Replace with specific limits if desired + rb_min, rb_max = barrier_pinch_offs[1], uy.max() # Replace with specific limits if desired + + # 3. Create a boolean mask matching the original 1D data structure + clip_mask = ( + (lb_data >= lb_min) & (lb_data <= lb_max) & + (rb_data >= rb_min) & (rb_data <= rb_max) + ) + + # 4. Apply the clipping mask to all arrays + lb_data = lb_data[clip_mask] + rb_data = rb_data[clip_mask] + current_data = current_data[clip_mask] + + # 5. Handle polarity and device typing if current_data[0] < 0: current_data = -current_data @@ -554,12 +579,17 @@ def extract_working_point(lb_data: np.array, current_data = np.flip(current_data, axis=None) device_type = 'electron' - # Now, we reshape the data into an array + # 6. Calculate new dimensions based on unique clipped values + nx_new = len(np.unique(lb_data)) + ny_new = len(np.unique(rb_data)) + # 7. Reshape the 1D clipped data into a 2D grid if current_data.ndim == 1: - nx = len(np.unique(lb_data)) - ny = len(np.unique(rb_data)) - current_data = current_data.reshape((ny, nx)) + # Verify the clipped size matches the expected 2D grid dimensions + if current_data.size == nx_new * ny_new: + current_data = current_data.reshape((ny_new, nx_new)) + else: + raise ValueError("Clipped data size does not form a perfect rectangular grid.") ny, nx = current_data.shape @@ -613,24 +643,20 @@ def extract_working_point(lb_data: np.array, x_index_arr = np.arange(nx) y_index_arr = np.arange(ny) - # enlarge region by adding 0.1 V to pinch-off values - x_idx_mid = np.interp(barrier_pinch_offs[0] + 0.05, lb_voltages, x_index_arr) - y_idx_mid = np.interp(barrier_pinch_offs[1] + 0.05, rb_voltages, y_index_arr) + # Selects mid point of x and y axes + x_idx_mid = np.interp((lb_voltages.min() + lb_voltages.max()) / 2, lb_voltages, x_index_arr) + y_idx_mid = np.interp((rb_voltages.min() + rb_voltages.max()) / 2, rb_voltages, y_index_arr) x_idx_mid = int(np.clip(x_idx_mid, 0, nx - 1)) y_idx_mid = int(np.clip(y_idx_mid, 0, ny - 1)) ridge_masked = np.zeros_like(ridge_filtered) if device_type == 'electron': - # Electron: analyze bottom-left, top-left, bottom-right (exclude top-right) + # Electron: analyze bottom-left ridge_masked[:y_idx_mid, :x_idx_mid] = ridge_filtered[:y_idx_mid, :x_idx_mid] - ridge_masked[y_idx_mid:, :x_idx_mid] = ridge_filtered[y_idx_mid:, :x_idx_mid] - ridge_masked[:y_idx_mid, x_idx_mid:] = ridge_filtered[:y_idx_mid, x_idx_mid:] else: # hole - # Hole: analyze top-right, top-left, bottom-right (exclude bottom-left) + # Hole: analyze top-right ridge_masked[y_idx_mid:, x_idx_mid:] = ridge_filtered[y_idx_mid:, x_idx_mid:] - ridge_masked[y_idx_mid:, :x_idx_mid] = ridge_filtered[y_idx_mid:, :x_idx_mid] - ridge_masked[:y_idx_mid, x_idx_mid:] = ridge_filtered[:y_idx_mid, x_idx_mid:] # From these edges, we detect lines using a probabilistic hough transform. # Use a slightly lower threshold and tune the minimum required segment length so long bottom-left lines are prioritized. @@ -645,27 +671,27 @@ def extract_working_point(lb_data: np.array, line_gap=hough_gap ) - roi = None - roi_offset = (0, 0) - if device_type == 'electron' and x_idx_mid > 5 and y_idx_mid > 5: - roi = ridge_masked[:y_idx_mid, :x_idx_mid] - roi_offset = (0, 0) - elif device_type != 'electron' and x_idx_mid < nx - 5 and y_idx_mid < ny - 5: - roi = ridge_masked[y_idx_mid:, x_idx_mid:] - roi_offset = (x_idx_mid, y_idx_mid) - - if roi is not None and roi.size > 0: - extra_lines = transform.probabilistic_hough_line( - roi, - threshold=max(5, hough_threshold - 3), - line_length=max(8, int(minLineLength * 0.12)), - line_gap=max(1, int(maxLineGap * 0.05)) - ) - for p0, p1 in extra_lines: - lines.append(( - (p0[0] + roi_offset[0], p0[1] + roi_offset[1]), - (p1[0] + roi_offset[0], p1[1] + roi_offset[1]) - )) + # roi = None + # roi_offset = (0, 0) + # if device_type == 'electron' and x_idx_mid > 5 and y_idx_mid > 5: + # roi = ridge_masked[y_idx_mid:, :x_idx_mid] + # roi_offset = (0, y_idx_mid) + # elif device_type == 'hole' and x_idx_mid < nx - 5 and y_idx_mid < ny - 5: + # roi = ridge_masked[:y_idx_mid, x_idx_mid:] + # roi_offset = (x_idx_mid, 0) + + # if roi is not None and roi.size > 0: + # extra_lines = transform.probabilistic_hough_line( + # roi, + # threshold=max(5, hough_threshold - 3), + # line_length=max(8, int(minLineLength * 0.12)), + # line_gap=max(1, int(maxLineGap * 0.05)) + # ) + # for p0, p1 in extra_lines: + # lines.append(( + # (p0[0] + roi_offset[0], p0[1] + roi_offset[1]), + # (p1[0] + roi_offset[0], p1[1] + roi_offset[1]) + # )) # if not lines: # return [] @@ -707,20 +733,20 @@ def extract_working_point(lb_data: np.array, line_candidates.sort(key=lambda item: -item[0]) filtered_lines = [entry[1] for entry in line_candidates] - if not filtered_lines and roi is not None and roi.size > 0: - for alt_img in [band_passed, G_uint, ridge_norm]: - alt_roi = alt_img[:y_idx_mid, :x_idx_mid] if device_type == 'electron' else alt_img[y_idx_mid:, x_idx_mid:] - extra_lines = transform.probabilistic_hough_line( - alt_roi, - threshold=max(4, hough_threshold - 4), - line_length=max(8, int(minLineLength * 0.12)), - line_gap=max(1, int(maxLineGap * 0.05)) - ) - for p0, p1 in extra_lines: - lines.append(( - (p0[0] + roi_offset[0], p0[1] + roi_offset[1]), - (p1[0] + roi_offset[0], p1[1] + roi_offset[1]) - )) + # if not filtered_lines and roi is not None and roi.size > 0: + # for alt_img in [band_passed, G_uint, ridge_norm]: + # alt_roi = alt_img[:y_idx_mid, :x_idx_mid] if device_type == 'electron' else alt_img[y_idx_mid:, x_idx_mid:] + # extra_lines = transform.probabilistic_hough_line( + # alt_roi, + # threshold=max(4, hough_threshold - 4), + # line_length=max(8, int(minLineLength * 0.12)), + # line_gap=max(1, int(maxLineGap * 0.05)) + # ) + # for p0, p1 in extra_lines: + # lines.append(( + # (p0[0] + roi_offset[0], p0[1] + roi_offset[1]), + # (p1[0] + roi_offset[0], p1[1] + roi_offset[1]) + # )) line_candidates = [] for p0, p1 in lines: @@ -745,8 +771,8 @@ def extract_working_point(lb_data: np.array, # Using the pinch-off voltages from barrier_pinch_offs parameter # enlarge region by adding 0.1 V to pinch-off values - lb_mid_volt = barrier_pinch_offs[0] + 0.05 # First value: x-axis (left/bottom gate) - rb_mid_volt = barrier_pinch_offs[1] + 0.05 # Second value: y-axis (right/bottom gate) + lb_mid_volt = (lb_voltages.min() + lb_voltages.max()) / 2 # First value: x-axis (left/bottom gate) + rb_mid_volt = (rb_voltages.min() + rb_voltages.max()) / 2 # Second value: y-axis (right/bottom gate) perp_candidates = [] perp_traces_for_plot = [] @@ -755,8 +781,6 @@ def extract_working_point(lb_data: np.array, perp_samples = 400 smooth_sigma = 2.0 - - # Now, for each filtered line, we define a line perpendicular to it, then find the peaks in current along them for x1, y1, x2, y2 in filtered_lines: @@ -829,9 +853,9 @@ def extract_working_point(lb_data: np.array, # restrict to red zone based on device type if device_type == 'electron': - valid = (vx < lb_mid_volt) | (vy < rb_mid_volt) + valid = (vx < lb_mid_volt) & (vy < rb_mid_volt) else: # hole - valid = (vx > lb_mid_volt) | (vy > rb_mid_volt) + valid = (vx > lb_mid_volt) & (vy > rb_mid_volt) peak_idx = peak_idx[valid] px = px[valid] py = py[valid] @@ -869,7 +893,6 @@ def extract_working_point(lb_data: np.array, # ---------- Selecting Final Bias Points ---------- - # First, we sort the points in order of increasing current perp_candidates.sort(key=lambda x: -x[0]) @@ -895,13 +918,16 @@ def extract_working_point(lb_data: np.array, for (_, vx, vy, _, _, _) in top_candidates ] + dist_to_pinch_off_corner = {} + # Compute selected working points. For Triple Dot, shift each point 0.1 V in the # opposite yellow trace direction instead of perpendicular to it. selected_working_points = [] traces_by_id = {tr["trace_id"]: tr for tr in perp_traces_for_plot} - for cand in top_candidates: + for i, cand in enumerate(top_candidates): _, vx_c, vy_c, px_c, py_c, tid = cand tr = traces_by_id.get(tid, None) + cand_point = np.array([vx_c, vy_c]) if tr is not None and str(DotTuning).strip().lower() == 'triple dot': try: vx_trace = np.interp(tr["px"], x_index_arr, lb_voltages) @@ -918,6 +944,8 @@ def extract_working_point(lb_data: np.array, selected_working_points.append((round(vx_c, 3), round(vy_c, 3))) else: selected_working_points.append((round(vx_c, 3), round(vy_c, 3))) + + dist_to_pinch_off_corner[tuple(cand_point)] = np.linalg.norm(cand_point - barrier_pinch_offs) perp_traces_for_plot = [ tr for tr in perp_traces_for_plot @@ -931,9 +959,9 @@ def extract_working_point(lb_data: np.array, vy = np.interp(tr["py"], y_index_arr, rb_voltages) if device_type == 'electron': - in_quad = (vx < lb_mid_volt) | (vy < rb_mid_volt) + in_quad = (vx < lb_mid_volt) & (vy < rb_mid_volt) else: # hole - in_quad = (vx > lb_mid_volt) | (vy > rb_mid_volt) + in_quad = (vx > lb_mid_volt) & (vy > rb_mid_volt) if not np.any(in_quad): continue @@ -958,10 +986,10 @@ def extract_working_point(lb_data: np.array, tr["chosen_block"] = chosen_block + closest_candidate, closest_value = min(dist_to_pinch_off_corner.items(), key=lambda kv: kv[1]) # ---------- Final Plotting ---------- - if plot_results: # Create figure and axes @@ -1057,120 +1085,74 @@ def round_to_step(x, step): return step * np.round(x / step) # Block Boundary and shaded region based on device type if device_type == 'electron': - # Electron: exclude top-right quadrant - # Top side: horizontal line from center to right edge + # Bottom-left quadrant boundary + + # Top edge of the bottom-left quadrant ax.plot( - [lb_mid_volt, lb_data.max()], # x: center → right - [rb_mid_volt, rb_mid_volt], # y constant at middle + [lb_data.min(), lb_mid_volt], # left edge -> midpoint + [rb_mid_volt, rb_mid_volt], # horizontal line at y midpoint linestyle='--', color='red', linewidth=1.2, alpha=0.9 ) - # Right side: vertical line from center to top edge + # Right edge of the bottom-left quadrant ax.plot( - [lb_mid_volt, lb_mid_volt], # x constant at center - [rb_mid_volt, rb_data.max()], # y: middle → top + [lb_mid_volt, lb_mid_volt], # vertical line at x midpoint + [rb_data.min(), rb_mid_volt], # bottom edge -> midpoint linestyle='--', color='red', linewidth=1.2, alpha=0.9 ) - # Bottom-left quadrant - rect1 = Rectangle( - (lb_data.min(), rb_data.min()), # bottom-left corner - lb_mid_volt - lb_data.min(), # width - rb_mid_volt - rb_data.min(), # height - facecolor='red', - alpha=0.2, - edgecolor=None, - zorder=2 - ) - ax.add_patch(rect1) - - # Top-left quadrant - rect2 = Rectangle( - (lb_data.min(), rb_mid_volt), # top-left corner - lb_mid_volt - lb_data.min(), # width - rb_data.max() - rb_mid_volt, # height - facecolor='red', - alpha=0.2, - edgecolor=None, - zorder=2 - ) - ax.add_patch(rect2) - - # Bottom-right quadrant - rect3 = Rectangle( - (lb_mid_volt, rb_data.min()), # bottom-right corner - lb_data.max() - lb_mid_volt, # width - rb_mid_volt - rb_data.min(), # height - facecolor='red', - alpha=0.2, - edgecolor=None, - zorder=2 - ) - ax.add_patch(rect3) + # Shade only the bottom-left quadrant + rect = Rectangle( + (lb_data.min(), rb_data.min()), + lb_mid_volt - lb_data.min(), + rb_mid_volt - rb_data.min(), + facecolor='red', + alpha=0.2, + edgecolor=None, + zorder=2 + ) + ax.add_patch(rect) else: # hole - # Hole: exclude bottom-left quadrant - # Bottom side: horizontal line from left edge to center + # Top-right quadrant boundary + + # Bottom edge of the top-right quadrant ax.plot( - [lb_data.min(), lb_mid_volt], # x: left → center - [rb_mid_volt, rb_mid_volt], # y constant at middle + [lb_mid_volt, lb_data.max()], # midpoint -> right edge + [rb_mid_volt, rb_mid_volt], # horizontal line at y midpoint linestyle='--', color='red', linewidth=1.2, alpha=0.9 ) - # Left side: vertical line from bottom edge to center + # Left edge of the top-right quadrant ax.plot( - [lb_mid_volt, lb_mid_volt], # x constant at center - [rb_data.min(), rb_mid_volt], # y: bottom → middle + [lb_mid_volt, lb_mid_volt], # vertical line at x midpoint + [rb_mid_volt, rb_data.max()], # midpoint -> top edge linestyle='--', color='red', linewidth=1.2, alpha=0.9 ) - # Top-right quadrant - rect1 = Rectangle( - (lb_mid_volt, rb_mid_volt), # top-right corner - lb_data.max() - lb_mid_volt, # width - rb_data.max() - rb_mid_volt, # height - facecolor='red', - alpha=0.2, - edgecolor=None, - zorder=2 - ) - ax.add_patch(rect1) - - # Top-left quadrant - rect2 = Rectangle( - (lb_data.min(), rb_mid_volt), # top-left corner - lb_mid_volt - lb_data.min(), # width - rb_data.max() - rb_mid_volt, # height - facecolor='red', - alpha=0.2, - edgecolor=None, - zorder=2 - ) - ax.add_patch(rect2) - - # Bottom-right quadrant - rect3 = Rectangle( - (lb_mid_volt, rb_data.min()), # bottom-right corner - lb_data.max() - lb_mid_volt, # width - rb_mid_volt - rb_data.min(), # height - facecolor='red', - alpha=0.2, - edgecolor=None, - zorder=2 - ) - ax.add_patch(rect3) + # Shade only the top-right quadrant + rect = Rectangle( + (lb_mid_volt, rb_mid_volt), + lb_data.max() - lb_mid_volt, + rb_data.max() - rb_mid_volt, + facecolor='red', + alpha=0.2, + edgecolor=None, + zorder=2 + ) + ax.add_patch(rect) # Hough lines for x1, y1, x2, y2 in filtered_lines: @@ -1187,6 +1169,8 @@ def round_to_step(x, step): return step * np.round(x / step) dot_tuning_shift = 0.1 if str(DotTuning).strip().lower() == 'triple dot' else 0.0 shifted_points = [] + best_shifted_point = None + green_circle = False for tr in perp_traces_for_plot: idx = tr.get("chosen_block", None) @@ -1204,7 +1188,7 @@ def round_to_step(x, step): return step * np.round(x / step) vx_p = float(vx[i]) vy_p = float(vy[i]) - # compute a local tangent along the yellow trace and shift along it + # compute a local tangent along the yellow trace and shift along it if 1 <= i < (len(vx) - 1): ddx = float(vx[i + 1]) - float(vx[i - 1]) ddy = float(vy[i + 1]) - float(vy[i - 1]) @@ -1236,8 +1220,19 @@ def round_to_step(x, step): return step * np.round(x / step) # shifted star (exactly dot_tuning_shift along the yellow perp) ax.scatter(sx, sy, s=200, c='white', marker='*', edgecolors='black', zorder=10) shifted_points.append((sx, sy)) - # hollow red circle at original peak position - ax.scatter(vx_p, vy_p, s=80, c='none', edgecolors='red', linewidths=1.5, zorder=11) + + if vx_p == closest_candidate[0] and vy_p == closest_candidate[1]: + best_shifted_point = (sx, sy) + green_circle = True + + if green_circle: + # hollow green circle at original peak position for best candidate + ax.scatter(vx_p, vy_p, s=80, c='none', edgecolors='white', linewidths=1.5, zorder=11) + green_circle = False + else: + # hollow red circle at original peak position + ax.scatter(vx_p, vy_p, s=80, c='none', edgecolors='red', linewidths=1.5, zorder=11) + # arrow from original to shifted star ax.annotate('', xy=(sx, sy), xytext=(vx_p, vy_p), arrowprops=dict(arrowstyle='->', color='black', lw=1.0), zorder=12) @@ -1846,9 +1841,9 @@ def round_to_step(x, step): return step * np.round(x / step) plt.show() if DotTuning == 'Triple Dot': - return shifted_points, perp_traces_for_plot, fig + return best_shifted_point, shifted_points, perp_traces_for_plot, fig elif DotTuning == 'SET': - return perp_bias_points, perp_traces_for_plot, fig + return best_shifted_point, perp_bias_points, perp_traces_for_plot, fig def extract_lever_arms(data: pd.DataFrame, plot_process: bool = False) -> dict: diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index a4b3873..8eb54ff 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -104,7 +104,7 @@ "metadata": {}, "outputs": [], "source": [ - "files = [\"B20S_2.csv\", \"B21S_2.csv\"]\n", + "files = [\"B2S_2.csv\", \"B3S_2.csv\"]\n", "barrier_pinch_offs = []\n", "for file in files:\n", " file_path_bg = file_path + file\n", @@ -183,8 +183,10 @@ "SETdata = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", "\n", "print(\"B2-B3 Sweep:\")\n", - "working_points, perp_lines, fig = da.extract_working_point(lbdata, rbdata, TDdata, [\"B2\", \"B3\"], 'Triple Dot', barrier_pinch_offs=barrier_pinch_offs, debug=False, plot_results=True)\n", - "display(fig)" + "for i in range(1):\n", + " best_point, working_points, perp_traces, fig = da.extract_working_point(lbdata, rbdata, TDdata, [\"B2\", \"B3\"], 'Triple Dot', barrier_pinch_offs=barrier_pinch_offs, debug=False, plot_results=True)\n", + " display(fig)\n", + " print(best_point)" ] } ], From 296fb506409bfd6f897a3f3a53a1741c4566d20e Mon Sep 17 00:00:00 2001 From: Drv5MC <116960417+Drv5MC@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:02:43 -0400 Subject: [PATCH 27/36] Added tunnel barrier tuning code tunnel barrier tuning along with updating function for finding max conductance points --- src/data_analysis.py | 276 ++++++++++++++++++++++++++++------- src/data_analysis_test.ipynb | 67 +++++++-- 2 files changed, 279 insertions(+), 64 deletions(-) diff --git a/src/data_analysis.py b/src/data_analysis.py index 7f7f516..c911aee 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -18,6 +18,7 @@ import pandas as pd import cv2 import scipy.signal as signal +from scipy.interpolate import make_smoothing_spline import matplotlib.cm as cm import matplotlib.pyplot as plt @@ -25,6 +26,8 @@ from matplotlib.ticker import AutoMinorLocator from matplotlib.patches import ConnectionPatch, Rectangle +from IPython.display import display + from scipy.optimize import curve_fit from scipy.special import expit from scipy.ndimage import convolve, map_coordinates, gaussian_filter1d @@ -414,7 +417,12 @@ def pinch_off_curve_ranges(x_data: np.array, return voltage_window, fig -def extract_max_conductance_points(self, x_data, y_data): +def extract_max_conductance_points(x_data: np.array, + y_data: np.array, + peak_height: list[float] = [None, None], + peak_prominence: list[float] = [None, None], + peak_width: list[float] = [None, None] + ): """Analyze current data to identify the largest conductance features. This function plots the current and its derivative, then highlights @@ -424,76 +432,93 @@ def extract_max_conductance_points(self, x_data, y_data): x1 = np.array(x_data) y1 = np.array(y_data) - # Plot - plt.figure(figsize=(8,6)) - plt.plot(x1, y1) - plt.xlabel('V_P (V)') - plt.ylabel('Current (nA)') - plt.title('Coulomb Blockade For P-Type Device') - - plt.show() - # Now, we calculate the derivative and replot dIdV = np.gradient(y1, x1) - posdIdV = abs(dIdV) + if peak_height == [None, None]: + peak_height = [0.25 * np.max(dIdV), 0.25 * np.max(dIdV)] + if peak_prominence == [None, None]: + peak_prominence = [0.3 * np.max(dIdV), 0.3 * np.max(dIdV)] - # Plot - plt.figure(figsize=(8,6)) - plt.plot(x1, posdIdV) - plt.xlabel('V_P (mV)') - plt.ylabel('Conductance (nS)') - plt.title('Conductance Peaks for P-Type Device') - - plt.show() # --- Find two largest and two smallest conductance points (positive + negative extremes) --- - # Get indices of top 2 positive conductance values - top_idx_pos = np.argsort(dIdV)[-2:] + peak_idx_pos, _ = signal.find_peaks(dIdV, height = peak_height[0], prominence = peak_prominence[0], width=peak_width[0]) + peak_idx_neg, test_props = signal.find_peaks(-dIdV, height = peak_height[1], prominence = peak_prominence[1], width=peak_width[1]) - # Get indices of bottom 2 negative conductance values - top_idx_neg = np.argsort(dIdV)[:2] + # for idx, p in enumerate(peak_idx_neg): + # # If the coordinate is near your dip (around V_P = 1.35 V) + # if 1.30 < x1[p] < 1.39: + # print(f"Negative Dip found at V_P = {x1[p]:.3f} V") + # print(f" -> Measured Prominence: {test_props['prominences'][idx]:.2f}") + # print(f" -> Measured Width: {test_props['widths'][idx]:.2f}") - # Combine them and sort by x-position for consistent plotting - top_idx = np.sort(np.concatenate([top_idx_pos, top_idx_neg])) + peak_idx = np.sort(np.concatenate([peak_idx_pos, peak_idx_neg])) # Extract the corresponding data points - x_top = x1.iloc[top_idx] - I_top = y1.iloc[top_idx] - G_top = dIdV[top_idx] + x_top = x1[peak_idx] + I_top = y1[peak_idx] + G_top = dIdV[peak_idx] + + max_idx = peak_idx[np.argmax(dIdV[peak_idx])] + min_idx = peak_idx[np.argmin(dIdV[peak_idx])] + + I_max = y1[max_idx] + I_min = y1[min_idx] + G_max = dIdV[max_idx] + G_min = dIdV[min_idx] + + best_sens_pts = [(x1[max_idx], I_max), (x1[min_idx], I_min)] # Create two subplots that share the x-axis fig, (ax1, ax2) = plt.subplots(2, 1, sharex=False, figsize=(8, 6)) # --- Top panel: Current --- ax1.plot(x1, y1, color='#2c5aa0', linewidth=1) - ax1.scatter(x_top, I_top, facecolors='none', edgecolors='#FF5500', s=100, linewidths=2, zorder=5, label='High Sensitivity Points') - ax1.set_ylabel('I (nA)', fontsize=45) + for i in range(len(x_top)): + if I_top[i] == I_max: + ax1.scatter(x_top[i], I_top[i], facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Max I') + elif I_top[i] == I_min: + ax1.scatter(x_top[i], I_top[i], facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Min I') + else: + ax1.scatter(x_top[i], I_top[i], facecolors='none', edgecolors="#FF5500", s=100, linewidths=2, zorder=5, label='High Sensitivity Points') + ax1.set_ylabel('I (nA)', fontsize=35) ax1.set_ylim(bottom=0) - ax1.set_xlim(min(x1) - 0.05, max(x1) + 0.05) + ax1.set_xlim(min(x1), max(x1)) ax1.tick_params(labelbottom=True) # --- Bottom panel: Conductance --- - ax2.plot(x1, posdIdV, color='#2c5aa0', linewidth=1) - ax2.scatter(x_top, G_top, facecolors='none', edgecolors='#FF5500', s=100, linewidths=2, zorder=5, label='Max G') - ax2.set_xlabel(r'$V_P$ (V)', fontsize=45) - ax2.set_ylabel('G (nS)', fontsize=45) - ax2.set_xlim(min(x1) - 0.05, max(x1) + 0.05) + ax2.plot(x1, dIdV, color='#2c5aa0', linewidth=1) + for i in range(len(x_top)): + if G_top[i] == G_max: + ax2.scatter(x_top[i], G_top[i], facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Max G') + elif G_top[i] == G_min: + ax2.scatter(x_top[i], G_top[i], facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Min G') + else: + ax2.scatter(x_top[i], G_top[i], facecolors='none', edgecolors="#FF5500", s=100, linewidths=2, zorder=5, label='High Sensitivity Points') + ax2.set_xlabel(r'$V_P$ (V)', fontsize=35) + ax2.set_ylabel('G (nS)', fontsize=35) + ax1.set_ylim(bottom=0) + ax2.set_xlim(min(x1), max(x1)) # --- Create the connection line --- - con = ConnectionPatch( - xyA=(x_top, I_top), coordsA=ax1.transData, - xyB=(x_top, G_top), coordsB=ax2.transData, - color='#FF5500', linestyle='--', linewidth=0.7 - ) - fig.add_artist(con) + for i in range(len(x_top)): + con = ConnectionPatch( + xyA=(x_top[i], I_top[i]), coordsA=ax1.transData, + xyB=(x_top[i], G_top[i]), coordsB=ax2.transData, + color='#FF5500', linestyle='--', linewidth=0.7 + ) + fig.add_artist(con) # --- Create a custom legend entry (hollow circle) --- legend_marker = mlines.Line2D([], [], color='#FF5500', marker='o', markerfacecolor='none', markersize=10, linewidth=0, label='High Sensitivity Points') + + legend_marker_2 = mlines.Line2D([], [], color='#01FF05', marker='o', + markerfacecolor='none', markersize=10, + linewidth=0, label='Best Sensitivity Points') # --- Custom tick labels: only min and max shown --- @@ -506,24 +531,26 @@ def extract_max_conductance_points(self, x_data, y_data): xticks = ax.get_xticks() yticks = ax.get_yticks() - ax1.set_xticks([-0.4, 0.0, 0.4]) - ax1.set_xticklabels(['-0.4', '0.0', '0.4'], fontsize=25) + ax1.set_xticks([np.round(x1.min(), 3), np.round((x1.min() + x1.max()) / 2, 3), np.round(x1.max(), 3)]) + ax1.set_xticklabels([str(np.round(x1.min(), 3)), str(np.round((x1.min() + x1.max()) / 2, 3)), str(np.round(x1.max(), 3))], fontsize=25) - ax1.set_yticks([0.0, 0.15]) - ax1.set_yticklabels(['0.0', '0.15'], fontsize=25) + ax1.set_yticks([0, np.round(y1.max(), 3)]) + ax1.set_yticklabels(['0', str(np.round(y1.max(), 3))], fontsize=25) - ax2.set_xticks([-0.4, 0.0, 0.4]) - ax2.set_xticklabels(['-0.4', '0.0', '0.4'], fontsize=25) + ax2.set_xticks([np.round(x1.min(), 3), np.round((x1.min() + x1.max()) / 2, 3), np.round(x1.max(), 3)]) + ax2.set_xticklabels([str(np.round(x1.min(), 3)), str(np.round((x1.min() + x1.max()) / 2, 3)), str(np.round(x1.max(), 3))], fontsize=25) - ax2.set_yticks([0.0, 10]) - ax2.set_yticklabels(['0', '10'], fontsize=25) + ax2.set_yticks([np.round(dIdV.min(), 1), 0, np.round(dIdV.max(), 1)]) + ax2.set_yticklabels([str(np.round(dIdV.min(), 1)), '0', str(np.round(dIdV.max(), 1))], fontsize=25) - ax1.legend(handles=[legend_marker], loc='upper left', fontsize=16, frameon=False) + ax1.legend(handles=[legend_marker, legend_marker_2], loc='upper left', fontsize=16, frameon=False) # --- Adjust layout --- plt.subplots_adjust(hspace=0.40) plt.show() + return best_sens_pts, G_top + def extract_working_point(lb_data: np.array, rb_data: np.array, current_data: np.array, @@ -1015,10 +1042,10 @@ def round_to_step(x, step): return step * np.round(x / step) x0, x1 = round_to_step(lb_data.min(), step), round_to_step(lb_data.max(), step) y0, y1 = round_to_step(rb_data.min(), step), round_to_step(rb_data.max(), step) - ax.set_xticks([lb_data.min(), lb_data.max()]) - ax.set_yticks([rb_data.min(), rb_data.max()]) - ax.set_xticklabels([str(lb_data.min()), str(lb_data.max())], fontsize=30) - ax.set_yticklabels([str(rb_data.min()), str(rb_data.max())], fontsize=30) + ax.set_xticks([np.round(lb_data.min(), 3), np.round(lb_data.max(), 3)]) + ax.set_yticks([np.round(rb_data.min(), 3), np.round(rb_data.max(), 3)]) + ax.set_xticklabels([str(np.round(lb_data.min(), 3)), str(np.round(lb_data.max(), 3))], fontsize=30) + ax.set_yticklabels([str(np.round(rb_data.min(), 3)), str(np.round(rb_data.max(), 3))], fontsize=30) ax.tick_params( which="major", @@ -1845,6 +1872,145 @@ def round_to_step(x, step): return step * np.round(x / step) elif DotTuning == 'SET': return best_shifted_point, perp_bias_points, perp_traces_for_plot, fig +def extract_tunnel_barrier_latching(dp_data: np.array, + tb_data: np.array, + current_data: np.array, + peak_height: list[float] = [None, None], + peak_prominence: list[float] = [None, None], + peak_width: list[float] = [None, None] + ): + """Analyze current data to identify if latching is occuring during dot-lead tuning + + This function plots the derivative of the current and analyzes the peaks to see if latching is occuring. + """ + + dp_data = np.array(dp_data) + tb_data = np.array(tb_data) + current_data = np.array(current_data) + device_type = 'electron' + num_traces = 25 + + if np.average(dp_data) < 0 and np.average(tb_data) < 0: + current_data = np.flip(current_data, axis=None) + device_type = 'hole' + + # Extract unique, sorted coordinate values for axis references + unique_dp = np.sort(np.unique(dp_data)) + unique_tb = np.sort(np.unique(tb_data)) + + nx = len(unique_dp) + ny = len(unique_tb) + + if current_data.ndim == 1: + if current_data.size == nx * ny: + current_data = current_data.reshape((ny, nx)) + else: + raise ValueError("Data size does not form a perfect rectangular grid.") + + ny, nx = current_data.shape + + # Calculate evenly spaced indices across the Y axis (tb_data) + y_indices = np.linspace(0, ny - 1, num_traces, dtype=int) + + # Extract the current rows corresponding to those Y indices + # Shape is (num_traces, nx) + sliced_current = current_data[y_indices, :] + + # Duplicate X axis (dp_data) to pair with every extracted trace row + x_broadcasted = np.repeat(unique_dp[np.newaxis, :], num_traces, axis=0) + + # Stack X and Current along the last axis -> shape (num_traces, nx, 2) + # Trace 0 matches the lowest unique_tb value, Trace -1 matches the highest. + final_traces = np.stack((x_broadcasted, sliced_current), axis=-1) + + # ========================================================================= + # PLOTTING BLOCK: Render the derivative subplots inside the function + # ========================================================================= + cols = 5 + rows = int(np.ceil(num_traces / cols)) + + # # Adjusted sharey=False because derivative scales can vary across the map + # fig, axes = plt.subplots(rows, cols, figsize=(15, 12), sharex=True, sharey=False) + # axes = axes.flatten() # Flatten grid into a 1D list for easy looping + + # Match the exact physical tb_data coordinate value for each indexed row slice + trace_y_values = unique_tb[y_indices] + + best_sens_pts_list = [] + all_sens_pts_list = [] + + for i in range(num_traces): + # Extract X (dp_data) and Z (current) data for this specific trace + x_vals = final_traces[i, :, 0] + z_vals = final_traces[i, :, 1] + + spline = make_smoothing_spline(x_vals, z_vals, lam=1e-9) + smoothed_z_vals = spline(x_vals) + + deriv = np.gradient(smoothed_z_vals, x_vals) + neg_deriv = -deriv + + if peak_height == [None, None]: + peak_height = [0.14 * deriv.max(), 0.3 * deriv.max()] + if peak_prominence == [None, None]: + peak_prominence = [0.1 * deriv.max(), 0.3 * neg_deriv.max()] + if peak_width == [None, None]: + peak_width = [(0, 40), (0, None)] + + print(f"Tunnel Barrier Voltage = {trace_y_values[i]:.3f} V") + + best_pts, all_pts = extract_max_conductance_points(x_vals, smoothed_z_vals, peak_height=peak_height, peak_prominence=peak_prominence, peak_width=peak_width) + best_sens_pts_list.append(best_pts) + all_sens_pts_list.append(all_pts) + + # # 1. Compute the derivative (dI/d_dp) using the spatial coordinate grid spacing + # derivative_vals = np.gradient(z_vals, x_vals) + + # # 2. Plot the derivative line to the corresponding grid cell + # axes[i].plot(x_vals, derivative_vals, color='tab:purple', linewidth=1.5) + + # # Place LaTeX formatted text overlay relative to the subplot viewport bounds + # axes[i].text( + # 0.05, 0.93, + # rf"$V_B$ = {trace_y_values[i]:.3f}", + # transform=axes[i].transAxes, + # fontsize=10, + # verticalalignment='top', + # bbox=dict(facecolor='white', alpha=0.7, edgecolor='none', pad=2) + # ) + + # # Clean up empty subplots if any + # for j in range(num_traces, len(axes)): + # fig.delaxes(axes[j]) + + # # Add global canvas labels and updated main title + # fig.supxlabel(r"$V_P$ (V)", fontsize=12) + # fig.supylabel("G (nS)", fontsize=12) + # fig.suptitle("Conductance Traces Extracted Along the Tunnel Barrier", fontsize=14, fontweight='bold') + + # plt.tight_layout() + # plt.show() + + negative_peak_count = 0 + negative_peak_count_list = [] + + for arrs in all_sens_pts_list: + for items in arrs: + if items < 0: + negative_peak_count += 1 + break + negative_peak_count_list.append(negative_peak_count) + + if negative_peak_count == 0: + barrier_voltage_set_point = tb_data.min() + return best_sens_pts_list, all_sens_pts_list, barrier_voltage_set_point + + final_number = negative_peak_count_list[-1] + first_index = negative_peak_count_list.index(final_number) + second_index = negative_peak_count_list.index(final_number, first_index + 1) + barrier_voltage_set_point = trace_y_values[second_index] + return best_sens_pts_list, all_sens_pts_list, barrier_voltage_set_point + def extract_lever_arms(data: pd.DataFrame, plot_process: bool = False) -> dict: """Estimate lever arms from a 2D transconductance map. diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index 8eb54ff..e9684dd 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -104,14 +104,14 @@ "metadata": {}, "outputs": [], "source": [ - "files = [\"B2S_2.csv\", \"B3S_2.csv\"]\n", + "files = [\"B20S_2.csv\", \"B21S_2.csv\"]\n", "barrier_pinch_offs = []\n", "for file in files:\n", " file_path_bg = file_path + file\n", " df = pd.read_csv(file_path_bg)\n", " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", - " TDdata = df[\"Triple Dot Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + " TDdata = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", " nf = np.average(TDdata[-20:])\n", "\n", " print(\"Barrier gate Resweep:\")\n", @@ -175,19 +175,68 @@ "metadata": {}, "outputs": [], "source": [ - "file_path_bbs = file_path + \"B2B3BBS.csv\"\n", + "file_path_bbs = file_path + \"Left Sensor Barrier Gate_Right Sensor Barrier Gate_Scan_2026-06-12_08-22-11.csv\"\n", "df = pd.read_csv(file_path_bbs)\n", - "lbdata = df[\"B2 (V)\"].to_numpy()\n", - "rbdata = df[\"B3 (V)\"].to_numpy()\n", - "TDdata = df[\"Triple Dot Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", - "SETdata = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "lbdata = df[\"spi_rack.module1.dac8.voltage\"].to_numpy()\n", + "rbdata = df[\"spi_rack.module1.dac7.voltage\"].to_numpy()\n", + "TDdata = df[\"agilent_left.volt\"].to_numpy()*1e-8*1e9 #nA\n", + "SETdata = df[\"agilent_right.volt\"].to_numpy()*1e-8*1e9 #nA\n", "\n", - "print(\"B2-B3 Sweep:\")\n", + "print(\"B20-B21 Sweep:\")\n", "for i in range(1):\n", - " best_point, working_points, perp_traces, fig = da.extract_working_point(lbdata, rbdata, TDdata, [\"B2\", \"B3\"], 'Triple Dot', barrier_pinch_offs=barrier_pinch_offs, debug=False, plot_results=True)\n", + " best_point, working_points, perp_traces, fig = da.extract_working_point(lbdata, rbdata, SETdata, [\"B20\", \"B21\"], 'SET', barrier_pinch_offs=(0.85, 0.85), debug=False, plot_results=True)\n", " display(fig)\n", " print(best_point)" ] + }, + { + "cell_type": "markdown", + "id": "11716eac", + "metadata": {}, + "source": [ + "### Coulomb Oscillations: Finding max conductance pts" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3ffda0d2", + "metadata": {}, + "outputs": [], + "source": [ + "file_path_co = file_path + \"P20S.csv\"\n", + "df = pd.read_csv(file_path_co)\n", + "Xdata = df[\"P20 (V)\"].to_numpy()\n", + "SETData = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "\n", + "best_pts, _ = da.extract_max_conductance_points(Xdata, SETData)\n", + "print(best_pts)" + ] + }, + { + "cell_type": "markdown", + "id": "140a3346", + "metadata": {}, + "source": [ + "### Dot - Lead Tuning" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d54a103", + "metadata": {}, + "outputs": [], + "source": [ + "file_path_dl = file_path + \"P0B0S.csv\" # P2B3S\n", + "df = pd.read_csv(file_path_dl)\n", + "Xdata = df[\"P0 (V)\"].to_numpy()\n", + "Ydata = df[\"B0 (V)\"].to_numpy()\n", + "SETData = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "\n", + "best_sens_pts_list, all_sens_pts_list = da.extract_tunnel_barrier_latching(Xdata, Ydata, SETData)\n", + "print(all_sens_pts_list)" + ] } ], "metadata": { From 603b17514f6bf65eeaec7b9d2c7cf290cb709b5c Mon Sep 17 00:00:00 2001 From: Drv5MC <116960417+Drv5MC@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:30:19 -0400 Subject: [PATCH 28/36] Autotuning code bugs fixed Errors fixed and code has been tested. --- src/data_analysis.py | 4 ++++ src/data_analysis_test.ipynb | 10 +++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/data_analysis.py b/src/data_analysis.py index c911aee..993ad2e 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -2004,11 +2004,15 @@ def extract_tunnel_barrier_latching(dp_data: np.array, if negative_peak_count == 0: barrier_voltage_set_point = tb_data.min() return best_sens_pts_list, all_sens_pts_list, barrier_voltage_set_point + if negative_peak_count == num_traces: + barrier_voltage_set_point = tb_data.max() + return best_sens_pts_list, all_sens_pts_list, barrier_voltage_set_point final_number = negative_peak_count_list[-1] first_index = negative_peak_count_list.index(final_number) second_index = negative_peak_count_list.index(final_number, first_index + 1) barrier_voltage_set_point = trace_y_values[second_index] + return best_sens_pts_list, all_sens_pts_list, barrier_voltage_set_point def extract_lever_arms(data: pd.DataFrame, diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index e9684dd..6bbe721 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -20,7 +20,7 @@ "import numpy as np\n", "import matplotlib.pyplot as plt\n", "\n", - "file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S FC\\\\\"" + "file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S SC\\\\\"" ] }, { @@ -228,14 +228,14 @@ "metadata": {}, "outputs": [], "source": [ - "file_path_dl = file_path + \"P0B0S.csv\" # P2B3S\n", + "file_path_dl = file_path + \"P0B0S_BT.csv\" # P2B3S\n", "df = pd.read_csv(file_path_dl)\n", "Xdata = df[\"P0 (V)\"].to_numpy()\n", "Ydata = df[\"B0 (V)\"].to_numpy()\n", - "SETData = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "SETData = df[\"SET Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", "\n", - "best_sens_pts_list, all_sens_pts_list = da.extract_tunnel_barrier_latching(Xdata, Ydata, SETData)\n", - "print(all_sens_pts_list)" + "best_sens_pts_list, all_sens_pts_list, set_point = da.extract_tunnel_barrier_latching(Xdata, Ydata, SETData)\n", + "print(set_point)" ] } ], From 1c894724c6a16147408376479b13a92702120c4d Mon Sep 17 00:00:00 2001 From: Ben Van Osch Date: Mon, 15 Jun 2026 09:57:15 -0400 Subject: [PATCH 29/36] First Cooldown Automated Tuning of 3D1S_w151_1 Changed config files to eliminate certain constraints that are now unused. Altered SET and Triple Dot config files to be used for testing the functions only. Added the autotuning_handler py file to have an additional autotuning thread. Finalized all bootstrapping logic with the exception of the pinch-off window function and the max conductance points function. Right now, the pinch-offs are being detected accurately, but not the saturation points for the accumulation gates or the plunger gates. Currently, the testing required before Bootstrapping is completed is: Refactoring and testing of new saturation point logic Testing of the charge sensor plunger sweep function Testing of the coulomb diamond function --- configs/Intel_Config.yaml | 46 +- configs/Intel_Config_SET.yaml | 24 +- configs/Intel_Config_Test.yaml | 31 - configs/Intel_Config_Triple_Dot.yaml | 32 +- src/autotuning_handler.py | 251 ++++ src/autotuning_protocol.py | 1717 +++++++++++++++++++++----- src/data_analysis.py | 120 +- src/experiment_base.py | 49 +- src/experiment_handler.py | 87 +- src/gui.py | 45 +- src/instrument_handler.py | 3 +- src/logger.py | 6 - src/main.py | 4 +- src/tunerlog.py | 6 +- 14 files changed, 1944 insertions(+), 477 deletions(-) delete mode 100644 configs/Intel_Config_Test.yaml create mode 100644 src/autotuning_handler.py diff --git a/configs/Intel_Config.yaml b/configs/Intel_Config.yaml index c880e7c..e04baac 100644 --- a/configs/Intel_Config.yaml +++ b/configs/Intel_Config.yaml @@ -7,7 +7,7 @@ device: constraints: abs_max_current_triple_dot: 20e-9 - abs_max_current_SET_dot: 10e-9 + abs_max_current_SET: 10e-9 abs_max_gate_differential: 1.5 abs_max_gate_voltage: 1.5 abs_max_ohmic_bias: 1e-3 @@ -16,87 +16,87 @@ device: S0: label: Left Dot Ohmic type: Dot Ohmic - channel: module1.dac10 + channel: spi_rack.module1.dac9.voltage S3: label: Right Sensor Ohmic type: Sensor Ohmic - channel: module1.dac5 + channel: spi_rack.module1.dac4.voltage SG1: label: Horizontal Screening Gate type: Central Screening - channel: module2.dac8 + channel: spi_rack.module2.dac7.voltage CG0a: label: Dot Screening Gate type: Dot Screening - channel: module2.dac7 + channel: spi_rack.module2.dac6.voltage CG0b: label: Sensor Screening Gate type: Sensor Screening - channel: module1.dac4 + channel: spi_rack.module1.dac3.voltage AC0: label: Left Dot Accumulation Gate type: Dot Accumulation - channel: module2.dac5 + channel: spi_rack.module2.dac4.voltage AC1: label: Right Dot Accumulation Gate type: Dot Accumulation - channel: module1.dac2 + channel: spi_rack.module1.dac1.voltage AC2: label: Right Sensor Accumulation Gate type: Sensor Accumulation - channel: module1.dac3 + channel: spi_rack.module1.dac2.voltage F1 + AC3: label: Left Sensor Accumulation Gate and Flanking Gates type: Sensor Accumulation - channel: module2.dac4 + channel: spi_rack.module2.dac3.voltage B0: label: Left Barrier Gate type: Dot Barrier - channel: module2.dac10 + channel: spi_rack.module2.dac9.voltage B1: label: Dot 1-2 Barrier Gate type: Dot Barrier - channel: module2.dac6 + channel: spi_rack.module2.dac5.voltage B2: label: Dot 2-3 Barrier Gate type: Dot Barrier - channel: module1.dac6 + channel: spi_rack.module1.dac5.voltage B3: label: Right Barrier Gate type: Dot Barrier - channel: module2.dac11 + channel: spi_rack.module2.dac10.voltage B20: label: Left Sensor Barrier Gate type: Sensor Barrier - channel: module1.dac9 + channel: spi_rack.module1.dac8.voltage B21: label: Right Sensor Barrier Gate type: Sensor Barrier - channel: module1.dac8 + channel: spi_rack.module1.dac7.voltage P0: label: Left Dot Plunger type: Dot Plunger - channel: module2.dac2 + channel: spi_rack.module2.dac1.voltage P1: label: Middle Dot Plunger type: Dot Plunger - channel: module2.dac1 + channel: spi_rack.module2.dac0.voltage P2: label: Right Dot Plunger type: Dot Plunger - channel: module1.dac1 + channel: spi_rack.module1.dac0.voltage P20: label: Sensor Plunger type: Sensor Plunger - channel: module2.dac3 + channel: spi_rack.module2.dac2.voltage setup: triple_dot_preamp: preamp_bias: 0.0 preamp_sensitivity: 1.0e-8 - SET_dot_preamp: + SET_preamp: preamp_bias: 0.0 preamp_sensitivity: 1.0e-8 voltage_dividers: - voltage_divider_triple_dot: 1.0e-3 - voltage_divider_SET_dot: 1.0e-3 + voltage_divider_triple_dot: 1.0e-2 + voltage_divider_SET: 1.0e-2 diff --git a/configs/Intel_Config_SET.yaml b/configs/Intel_Config_SET.yaml index 36f5eae..a30bfad 100644 --- a/configs/Intel_Config_SET.yaml +++ b/configs/Intel_Config_SET.yaml @@ -7,7 +7,7 @@ device: constraints: abs_max_current_triple_dot: 20e-9 - abs_max_current_SET_dot: 10e-9 + abs_max_current_SET: 10e-9 abs_max_gate_differential: 1.5 abs_max_gate_voltage: 1.5 abs_max_ohmic_bias: 1e-3 @@ -16,43 +16,43 @@ device: S3: label: Right Sensor Ohmic type: Sensor Ohmic - channel: module1.dac11 + channel: spi_rack.module1.dac10.voltage SG1: label: Horizontal Screening Gate type: Central Screening - channel: module1.dac12 + channel: spi_rack.module1.dac11.voltage CG0b: label: Sensor Screening Gate type: Sensor Screening - channel: module1.dac13 + channel: spi_rack.module1.dac12.voltage AC2: label: Right Sensor Accumulation Gate type: Sensor Accumulation - channel: module1.dac14 + channel: spi_rack.module1.dac13.voltage F1 + AC3: label: Left Sensor Accumulation Gate and Flanking Gates type: Sensor Accumulation - channel: module1.dac15 + channel: spi_rack.module1.dac14.voltage B20: label: Left Sensor Barrier Gate type: Sensor Barrier - channel: module1.dac16 + channel: spi_rack.module2.dac14.voltage B21: label: Right Sensor Barrier Gate type: Sensor Barrier - channel: module2.dac14 + channel: spi_rack.module2.dac13.voltage P20: label: Sensor Plunger type: Sensor Plunger - channel: module2.dac15 + channel: spi_rack.module1.dac15.voltage setup: triple_dot_preamp: preamp_bias: 0.0 preamp_sensitivity: 1.0e-8 - SET_dot_preamp: + SET_preamp: preamp_bias: 0.0 preamp_sensitivity: 1.0e-8 voltage_dividers: - voltage_divider_triple_dot: 1.0e-3 - voltage_divider_SET_dot: 1.0e-3 + voltage_divider_triple_dot: 1.0e-2 + voltage_divider_SET: 1.0e-2 diff --git a/configs/Intel_Config_Test.yaml b/configs/Intel_Config_Test.yaml deleted file mode 100644 index 0e4e566..0000000 --- a/configs/Intel_Config_Test.yaml +++ /dev/null @@ -1,31 +0,0 @@ -device: - - characteristics: - name: 3D1S_w151_1 - charge_carrier: e - operation_mode: acc - - constraints: - abs_max_current_triple_dot: 20e-9 - abs_max_current_SET_dot: 10e-9 - abs_max_gate_differential: 1.5 - abs_max_gate_voltage: 1.5 - abs_max_ohmic_bias: 1e-3 - - gates: - S0: - label: Left Dot Ohmic - type: Dot Ohmic - channel: module2.dac16 - SG1: - label: Horizontal Screening Gate - type: Central Screening - channel: module2.dac15 - AC0: - label: Left Accumulation Gate - type: Dot Accumulation - channel: module2.dac14 - B0: - label: Left Dot Barrier Gate - type: Dot Barrier - channel: module2.dac13 \ No newline at end of file diff --git a/configs/Intel_Config_Triple_Dot.yaml b/configs/Intel_Config_Triple_Dot.yaml index 3797eb5..2227677 100644 --- a/configs/Intel_Config_Triple_Dot.yaml +++ b/configs/Intel_Config_Triple_Dot.yaml @@ -7,7 +7,7 @@ device: constraints: abs_max_current_triple_dot: 20e-9 - abs_max_current_SET_dot: 10e-9 + abs_max_current_SET: 10e-9 abs_max_gate_differential: 1.5 abs_max_gate_voltage: 1.5 abs_max_ohmic_bias: 1e-3 @@ -16,59 +16,59 @@ device: S0: label: Left Dot Ohmic type: Dot Ohmic - channel: module1.dac7 + channel: spi_rack.module1.dac6.voltage SG1: label: Horizontal Screening Gate type: Central Screening - channel: module1.dac11 + channel: spi_rack.module1.dac10.voltage CG0a: label: Dot Screening Gate type: Dot Screening - channel: module1.dac12 + channel: spi_rack.module1.dac11.voltage AC0: label: Left Dot Accumulation Gate type: Dot Accumulation - channel: module1.dac13 + channel: spi_rack.module1.dac12.voltage AC1: label: Right Dot Accumulation Gate type: Dot Accumulation - channel: module1.dac14 + channel: spi_rack.module1.dac13.voltage B0: label: Left Barrier Gate type: Dot Barrier - channel: module1.dac15 + channel: spi_rack.module2.dac13.voltage B1: label: Dot 1-2 Barrier Gate type: Dot Barrier - channel: module1.dac16 + channel: spi_rack.module2.dac14.voltage B2: label: Dot 2-3 Barrier Gate type: Dot Barrier - channel: module2.dac12 + channel: spi_rack.module2.dac11.voltage B3: label: Right Barrier Gate type: Dot Barrier - channel: module2.dac13 + channel: spi_rack.module2.dac12.voltage P0: label: Left Dot Plunger type: Dot Plunger - channel: module2.dac14 + channel: spi_rack.module1.dac14.voltage P1: label: Middle Dot Plunger type: Dot Plunger - channel: module2.dac15 + channel: spi_rack.module1.dac15.voltage P2: label: Right Dot Plunger type: Dot Plunger - channel: module2.dac16 + channel: spi_rack.module2.dac15.voltage setup: triple_dot_preamp: preamp_bias: 0.0 preamp_sensitivity: 1.0e-8 - SET_dot_preamp: + SET_preamp: preamp_bias: 0.0 preamp_sensitivity: 1.0e-8 voltage_dividers: - voltage_divider_triple_dot: 1.0e-3 - voltage_divider_SET_dot: 1.0e-3 + voltage_divider_triple_dot: 1.0e-2 + voltage_divider_SET: 1.0e-2 diff --git a/src/autotuning_handler.py b/src/autotuning_handler.py new file mode 100644 index 0000000..99797ec --- /dev/null +++ b/src/autotuning_handler.py @@ -0,0 +1,251 @@ +''' +File: experiment_thread.py +Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) + +This file contains classes related to running experiments from the autotuning code. Experiments are put into a queue, which +keeps track of which experiments to in which order. The queue can be stached and wait for user input to continue, or can be cleared +with an Abort call from the user. +''' + +# Imports + + +import threading +from queue import PriorityQueue +from collections.abc import Callable +from dataclasses import dataclass +from instrument_handler import TunerFuture +from enum import Enum +from typing import Tuple, Dict, Any, Literal, Protocol, Optional, Deque +from qcodes.instrument import Instrument +from tunerlog import TunerLog + +from autotuning_protocol import Bootstrapping, GlobalChargeTuning, VirtualGating, ChargeStateTuning, QubitTuning + +_AutotuningThreadInstance = None +_AutotuningHandlerInstance = None + +logger = TunerLog('Autotuning Handler') + +def create_autotuning_thread(): + global _AutotuningThreadInstance + + if _AutotuningThreadInstance is None: + _AutotuningThreadInstance = AutotuningThread() + _AutotuningThreadInstance.run() + + return _AutotuningThreadInstance + +def get_autotuning_handler(): + global _AutotuningHandlerInstance + + if _AutotuningHandlerInstance is None: + thread = create_autotuning_thread() + _AutotuningHandlerInstance = autotuning_handler(thread) + + return _AutotuningHandlerInstance + +class Auto_status(Enum): + queued = "Queued" + running = "Running" + failed = "Failed" + invalid = "Invalid" + +class AutotuningCallback(Protocol): + def __call__(self, instrument: Instrument, *args: Any) -> Any: + ... + +@dataclass +class autotuning_job: + future : TunerFuture + when : float + type : str + +class autotuning_callback_job(autotuning_job): + def __init__(self, future : TunerFuture, callback : AutotuningCallback, *args, when : float = -1): + self.callback : Callable[[Instrument], Any] = lambda inst: callback(inst, args) + super().__init__(future, when, "instrument_callback") + +class AutotuningThread: + + def __init__(self): + + self.job_event = threading.Event() + self.abort_event = threading.Event() + self.shutdown_event = threading.Event() + self.job_queue = PriorityQueue() + self.THREAD_NAME = "AutotuningThread" + self.thread = threading.Thread(target = self.__thread_loop__, name = self.THREAD_NAME) + + def run(self): + + self.thread.start() + + def join(self): + + print("Stopping the autotuning thread...") + self.shutdown_event.set() + self.thread.join() + + def __assert_correct_thread__(self): + + assert threading.current_thread().name == self.THREAD_NAME, f"The current thread, {threading.current_thread().name}, is not the Autotuning Thread." + + def add_job(self, + f: Callable, + args: tuple = (), + priority: int = 1, + wait: bool = True, + timeout: float = None): + + future = TunerFuture() + + job = (priority, (f, args, future)) + self.job_queue.put(job) + + # Wake the thread + self.job_event.set() + + if wait: + return future.result(timeout) + return future + + def abort(self): + + self.abort_event.set() + + def __thread_loop__(self): + + print("Starting the Autotuning Thread Worker") + + while not self.shutdown_event.is_set(): + + self.job_event.wait() + + while self.job_queue.qsize() > 0: + + if self.abort_event.is_set(): # Clear remaining jobs safely + while not self.job_queue.empty(): + try: + _, (_, _, future) = self.job_queue.get_nowait() + future.set_exception(RuntimeError("Autotuning Stage aborted")) + self.job_queue.task_done() + except: + break + self.abort_event.clear() + continue + + priority, data = self.job_queue.get() + f, args, future = data + + try: + result = f(*args, self.abort_event) + except Exception as e: + future.set_exception(e) + else: + future.set_result(result) + + self.job_queue.task_done() + + # reset event once queue is empty + self.job_event.clear() + + logger.info("Exiting Thread!") + +class autotuning_handler: + + def __init__(self, autotuning_thread): + self.autotuning_thread = autotuning_thread + + def run_bootstrapping(self, + device_config, + instrument_handler, + experiment_handler, + wait: bool = True, + timeout: float = 6000): + + def sweep_fn(abort_event): + result = Bootstrapping(device_config = device_config, + instrument_handler = instrument_handler, + experiment_handler = experiment_handler + ) + return result + + return self.autotuning_thread.add_job( + sweep_fn, + args=(), + wait=wait, + timeout=timeout + ) + + def run_global_charge_tuning(self, + sweep, + instrument_handler, + current_setpoints = {}, + wait: bool = True, + timeout: float = 60): + + def sweep_fn(abort_event): + result = GlobalChargeTuning() + return result + + return self.autotuning_thread.add_job( + sweep_fn, + args=(), + wait=wait, + timeout=timeout + ) + + def run_virtual_gating(self, + sweep, + instrument_handler, + current_setpoints = {}, + wait: bool = True, + timeout: float = 60): + + def sweep_fn(abort_event): + result = VirtualGating() + return result + + return self.autotuning_thread.add_job( + sweep_fn, + args=(), + wait=wait, + timeout=timeout + ) + + def run_charge_state_tuning(self, + sweep, + instrument_handler, + current_setpoints = {}, + wait: bool = True, + timeout: float = 60): + + def sweep_fn(abort_event): + result = ChargeStateTuning() + return result + + return self.autotuning_thread.add_job( + sweep_fn, + args=(), + wait=wait, + timeout=timeout + ) + + def run_qubit_tuning(self, + sweep, + instrument_handler, + current_setpoints = {}, + wait: bool = True, + timeout: float = 60): + + def sweep_fn(abort_event): + result = QubitTuning() + return result + + return self.autotuning_thread.add_job( + sweep_fn, + args=(), + wait=wait, + timeout=timeout + ) \ No newline at end of file diff --git a/src/autotuning_protocol.py b/src/autotuning_protocol.py index c63b873..fee96fc 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -26,19 +26,25 @@ from skimage.filters import threshold_otsu from skimage.morphology import diamond, rectangle # noqa -from main import gui +from datetime import datetime +import threading from experiment_base import SweepParam, SweepLayer, Sweep +from data_analysis import extract_turn_on_voltage, extract_pinch_off_curve_ranges, extract_working_point, extract_max_conductance_points, extract_lever_arms import sys from nicegui import ui from tunerlog import TunerLog -logger = TunerLog('Autotuner') +logger = TunerLog('Autotuning Protocol') class Protocol: - def __init__(self, device_config): + def __init__(self, + device_config, + instrument_handler=None, + experiment_handler=None + ): ''' Initializes the protocol. Reads the device configuration file provided and creates a path from gate name to dac. @@ -46,12 +52,17 @@ def __init__(self, device_config): ''' + self.instrument_handler = instrument_handler + self.experiment_handler = experiment_handler + # First, we load in the config file logger.info("Loading Device Config file...") self._load_config_file(device_config) + self.directory = r"C:\Users\BaughLaflamme\Desktop\3d1s_W151_1 Measurements\3D1S_w151_1 - Autotuning Tests" + # Now, we create a dictionary to house a map between gate names and dacs self.gates_to_dacs = {} @@ -60,7 +71,7 @@ def __init__(self, device_config): self.gates_to_dacs[i] = self.device_gates[i]['channel'] - print(self.gates_to_dacs) + #print(self.gates_to_dacs) def _load_config_file(self, device_config): @@ -126,32 +137,93 @@ def _load_config_file(self, device_config): # Contraints self.abs_max_current_triple_dot = self.config['device']['constraints']['abs_max_current_triple_dot'] - self.abs_max_current_SET_dot = self.config['device']['constraints']['abs_max_current_SET_dot'] + self.abs_max_current_SET = self.config['device']['constraints']['abs_max_current_SET'] self.abs_max_gate_voltage = self.config['device']['constraints']['abs_max_gate_voltage'] self.abs_max_gate_differential = self.config['device']['constraints']['abs_max_gate_differential'] # Equipment Setup self.voltage_divider_triple_dot = self.config['setup']['voltage_dividers']['voltage_divider_triple_dot'] - self.voltage_divider_SET_dot = self.config['setup']['voltage_dividers']['voltage_divider_SET_dot'] + self.voltage_divider_SET = self.config['setup']['voltage_dividers']['voltage_divider_SET'] self.triple_dot_preamp_bias = self.config['setup']['triple_dot_preamp']['preamp_bias'] self.triple_dot_preamp_sensitivity = self.config['setup']['triple_dot_preamp']['preamp_sensitivity'] - self.SET_dot_preamp_bias = self.config['setup']['SET_dot_preamp']['preamp_bias'] - self.SET_dot_preamp_sensitivity = self.config['setup']['SET_dot_preamp']['preamp_sensitivity'] + self.SET_preamp_bias = self.config['setup']['SET_preamp']['preamp_bias'] + self.SET_preamp_sensitivity = self.config['setup']['SET_preamp']['preamp_sensitivity'] class Bootstrapping(Protocol): - def __init__(self, device_config): - super().__init__(device_config = device_config) + def __init__(self, device_config, instrument_handler, experiment_handler): + + super().__init__(device_config = device_config, instrument_handler = instrument_handler, experiment_handler = experiment_handler) self.noise_floor = None - self.ground_device(instr_handler = gui.instrument_handler) + self.ground_device(instr_handler = instrument_handler, exp_handler = experiment_handler) + + self.noise_floor = self.measure_noise_floor() + + logger.info(f"The noise floor is: {self.noise_floor}") + + names = self.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'], + ).keys() + + self.means = [] + + for i in names: + + mean_name = i + "_mean" + mean = self.noise_floor[mean_name] + + self.means.append(mean) + + logger.info(f"{self.means}") + + turn_on_voltages = self.turn_on(ohmic_bias = -2e-4, + screening_voltage = 0.2, + gate_voltage = 1.5, + num_points = 200) + + logger.info(f"{turn_on_voltages}") + + pinch_off_voltages, saturation_voltages = self.pinch_off(gate_voltage = 1.5, + final_voltages = turn_on_voltages, + num_points = 200) + + + logger.info("Into Barrier-Barrier!") + + logger.info(f"{pinch_off_voltages}") + logger.info(f"{saturation_voltages}") + + new_sat_voltages = [] + + for i in saturation_voltages: + + i += 0.1 + new_sat_voltages.append(i) + + working_point = self.barrier_barrier_sweep(lower_voltages = pinch_off_voltages, + upper_voltages = new_sat_voltages, + num_points = 200) + + sensor_barrier_voltages = list(working_point) + + plunger_starting_voltages = [sum(working_point) / len(working_point)] - self.measure_noise_floor() + sensing_point = self.coulomb_blockade_sweep(sensor_barrier_voltages = sensor_barrier_voltages, + lower_voltages = plunger_starting_voltages, + upper_voltages = [1.5], + num_points = 200) - def ground_device(self, instr_handler): + """ self.coulomb_diamonds(lower_sd_voltages = [-0.01], + upper_sd_voltages = [0.01], + lower_plunger_voltages = [0.7], + upper_plunger_voltages = [1.5], + num_points = 100) """ + + def ground_device(self, instr_handler, exp_handler): # First, we grab all the connected dacs and current values @@ -161,95 +233,169 @@ def ground_device(self, instr_handler): p = self.device_gates[i]['channel'] - instr, param = p.parameter.split('.', 1) + instr, param = p.split('.', 1) dacs_and_vals[i] = instr_handler.get_parameter( instr, param, wait=True ) - - print(dacs_and_vals) # Now, we create the sweep parameters targets = [] - for i in dacs_and_vals: + for gate, dac_and_val in dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): - param = SweepParam( - parameter = i, - start = dacs_and_vals[i], - end = 0.0 - ) - - targets.append(param) + param = SweepParam( + parameter = "spi_rack." + dac, + start = starting_val, + end = 0.0 + ) - print(targets) + targets.append(param) sweep_layer = SweepLayer( targets = targets, - num_points = 200, + num_points = 100, measurement_time = 0.1 ) + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + logger.info("Grounding Device...") - future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + future = exp_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = instr_handler) logger.info("Device Grounded!") - def measure_noise_floor(self): + def measure_noise_floor(self, measurement_time = 30): # We collect the readout buffer for 1 minute and average the values to measure the noise floor. - self.noise_floor = gui.instrument_handler.read_buffer( - ['agilent_left.volt', 'agilent_right.volt'], - t_avg = 0, - t_stop = 60 - ) + logger.info("Starting noise floor measurement...") + + stats = { + 'agilent_left.volt': { + 'n': 0, + 'mean': 0.0, + 'M2': 0.0, + 'samples': [] + }, + 'agilent_right.volt': { + 'n': 0, + 'mean': 0.0, + 'M2': 0.0, + 'samples': [] + } + } + + for i in range(measurement_time): + + vals = self.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'], + ) + + # Extract both values for each DMM + + for name, x in vals.items(): + + s = stats[name] + + s['samples'].append(x) + + s['n'] += 1 + n = s['n'] + + delta = x - s['mean'] + s['mean'] += delta / n + delta2 = x - s['mean'] + + s['M2'] += delta2 * delta + + results = {} + + logger.info("Analyzing!") + + for name, s in stats.items(): + + mean = s['mean'] + + if s['n'] > 1: + + variance = s['M2'] / (s['n'] - 1) + std = np.sqrt(variance) + else: + std = 0.0 + + samples = np.array(s['samples']) + median = np.median(samples) + mad = np.median(np.abs(samples - median)) + + robust_std = 1.4826 * mad + + results[f"{name}_mean"] = mean + results[f"{name}_std"] = std + results[f"{name}_median"] = median + results[f"{name}_mad"] = mad + results[f"{name}_robust_std"] = robust_std + + #print("Noise Floor Found!") + + return results def turn_on(self, ohmic_bias, screening_voltage, gate_voltage, num_points): # First we grab all the dacs and values to set to our ohmics - + ohmic_targets = [] for i in self.gates_to_dacs: - if self.device_gates[i]['type'] == "Ohmic": + if self.device_gates[i]['type'] == "Dot Ohmic" or self.device_gates[i]['type'] == "Sensor Ohmic": p = self.device_gates[i]['channel'] - instr, param = p.parameter.split('.', 1) - - ohmic_voltage = ohmic_bias / self.voltage_divider_SET_dot + ohmic_voltage = ohmic_bias / self.voltage_divider_SET sparam = SweepParam( - parameter = param, + parameter = p, start = 0.0, end = ohmic_voltage ) ohmic_targets.append(sparam) - print(ohmic_targets) - sweep_layer = SweepLayer( targets = ohmic_targets, num_points = num_points, - measurement_time = 0.1 + measurement_time = 0.05 ) + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + logger.info("Setting Ohmic Bias...") - future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) logger.info("Ohmic Bias Set!") @@ -265,30 +411,34 @@ def turn_on(self, ohmic_bias, screening_voltage, gate_voltage, num_points): p = self.device_gates[i]['channel'] - instr, param = p.parameter.split('.', 1) - sparam = SweepParam( - parameter = param, + parameter = p, start = 0.0, end = screening_voltage ) screening_targets.append(sparam) - - print(screening_targets) sweep_layer = SweepLayer( targets = screening_targets, num_points = num_points, - measurement_time = 0.1 + measurement_time = 0.05 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] ) + sweep = Sweep([sweep_layer], measure) + logger.info("Setting Screening Gate Voltages...") - future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) logger.info("Screening Gate Voltages Set!") @@ -304,395 +454,1386 @@ def turn_on(self, ohmic_bias, screening_voltage, gate_voltage, num_points): p = self.device_gates[i]['channel'] - instr, param = p.parameter.split('.', 1) - sparam = SweepParam( - parameter = param, + parameter = p, start = 0, end = gate_voltage ) - gate_targets.append(param) - - print(gate_targets) + gate_targets.append(sparam) sweep_layer = SweepLayer( targets = gate_targets, num_points = num_points, - measurement_time = 0.3 + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] ) + sweep = Sweep([sweep_layer], measure) + logger.info("Device Turn-On Starting...") - future = gui.experiment_handler.do_sweep(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + + filename = "Turn_On_" + time + ".csv" + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename) logger.info("Device Turn-On Sweep Complete! Confirming Turn-On...") - # Now, we determine if there was a measured current above the noise floor. If so, we fit our data to the ReLU function + turn_on_measurement = self.measure_noise_floor() + names = self.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'], + ).keys() + + means = [] + for i in names: + mean_name = i + "_mean" + mean = turn_on_measurement[mean_name] + means.append(mean) - def pinch_off(self, gate_voltage, final_voltage, num_points): + """ + Now, we compare the means of each measurement. + If the mean measured after turn-on is larger than 10 times the noise floor mean, + then we say that the device has turned on. + + """ + turn_on_check = [] - # We first construct a loop to pinch-off each individual gate + for i, item in enumerate(self.means): - excluded_types = ["Dot Ohmic", "Sensor Ohmic", "Dot Screening", "Sensor Screening", "Central Screening"] + if abs(10 * item) < means[i]: + turn_on_check.append(True) + else: + turn_on_check.append(False) - for i in self.gates_to_dacs: + if all(item is True for item in turn_on_check): - if self.device_gates[i]['type'] not in excluded_types: + logger.info("Turn-Ons Confirmed Succussfully! Determining Turn-On Voltages...") - p = self.device_gates[i]['channel'] + # Get the data from the CSV - instr, param = p.parameter.split('.', 1) + filepath = os.path.join(self.directory, filename) - sparam = SweepParam( - parameter = param, - start = gate_voltage, - end = final_voltage - ) + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) - sweep_layer = SweepLayer( - targets = sparam, - num_points = num_points, - measurement_time = 0.3 - ) + # Get the data for DMMs and for the set voltages, and convert the data from voltage to current, then to nA. We also convert the mean - logger.info(f"{self.device_gates[i]['label']} Starting Pinch-Off...") + turn_on_sweep = df.iloc[:,0].to_numpy() - future = gui.experiment_handler.do_sweep(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + triple_dot_data = df.iloc[:,-2].to_numpy() * self.triple_dot_preamp_sensitivity * 1e9 - logger.info(f"{self.device_gates[i]['label']} Pinch-Off Complete! Confirming Pinch-Off...") + SET_data = df.iloc[:,-1].to_numpy() * self.SET_preamp_sensitivity * 1e9 - """ - Now, we determine if there was a measured current comparable to the noise floor. - If so, we fit our data to the sigmoid function + current_means = [] - """ + for mean in self.means: + mean *= self.SET_preamp_sensitivity * 1e9 + current_means.append(mean) - def SET_current_check(self, instr_handler, minimum_current, maximum_current): + current_data = [triple_dot_data, SET_data] - # First, we need to read the current and check if it is above or below the current values specified. + turn_on_voltages = [] - self.current_level = gui.instrument_handler.read_buffer( - ['agilent_left.volt', 'agilent_right.volt'], - t_avg = 0, - t_stop = 60 - ) + turn_on_filenames = ['Triple_Dot_Turn_On' + time + '.png', 'SET_Turn_On' + time + '.png'] - # Here, we get the current accumulation voltages + for i, mean in enumerate(self.means): - included_types = ['Dot Accumulation', 'Sensor Accumulation'] + turnon_voltage, fig = extract_turn_on_voltage(x_data = turn_on_sweep, + y_data = current_data[i], + noisefloor = current_means[i], + filepath = self.directory, + filename = turn_on_filenames[i], + plot_results = True) - self.accumulation_voltages = {} + turn_on_voltages.append(turnon_voltage) - for i in self.gates_to_dacs: + return turn_on_voltages - if i in included_types: + else: - p = self.device_gates[i]['channel'] + logger.info(""" + Turn-On confirmation failed for one or more channels! + Please confirm that all gates needed for Turn-On are being swept, + otherwise the device does not turn-on for one or more channels within the given maximum voltage. + """) + + self.ground_device() - instr, param = p.parameter.split('.', 1) + return None - self.accumulation_voltages[i] = instr_handler.get_parameter( - instr, - param, - wait=True - ) + def pinch_off(self, gate_voltage, final_voltages, num_points): - if self.current_level > maximum_current: + """ + We first construct a loop to pinch-off each individual finger gate other than the barrier gates, + which we'll do the same for after adjusting the other gates. + + """ - # We reduce the voltages on the accumulation gates by 1 mV + excluded_types = ["Dot Ohmic", "Sensor Ohmic", "Dot Screening", "Sensor Screening", "Central Screening", "Dot Barrier", "Sensor Barrier"] - self.accumulation_voltages -= 1e-3 + triple_dot_turn_on, SET_turn_on = final_voltages - for i in self.accumulation_voltages: + pinch_off_voltages= [] - p = self.device_gates[i]['channel'] + saturation_voltages = [] - instr, param = p.parameter.split('.', 1) + for i in self.gates_to_dacs: - instr_handler.set_parameter( - instr, - {param: self.accumulation_voltages[i]}, - wait=True - ) + if self.device_gates[i]['type'] not in excluded_types: - self.SET_current_check(minimum_current, maximum_current) - - elif self.current_level < minimum_current: + if self.device_gates[i]['type'].startswith('Dot'): - # We increase the voltages on the accumulation gates by 1 mV + result = self.pinch_off_iterative(gate_voltage = gate_voltage, + final_voltage = triple_dot_turn_on, + gate_name = self.device_gates[i]['label'], + gate_type = self.device_gates[i]['type'], + channel = self.device_gates[i]['channel'], + num_points = num_points) - self.accumulation_voltages += 1e-3 + pinch_off_voltages.append(result[0]) + saturation_voltages.append(result[1]) - for i in self.accumulation_voltages: + elif self.device_gates[i]['type'].startswith('Sensor'): - p = self.device_gates[i]['channel'] + result = self.pinch_off_iterative(gate_voltage = gate_voltage, + final_voltage = SET_turn_on, + gate_name = self.device_gates[i]['label'], + gate_type = self.device_gates[i]['type'], + channel = self.device_gates[i]['channel'], + num_points = num_points) - instr, param = p.parameter.split('.', 1) + pinch_off_voltages.append(result[0]) + saturation_voltages.append(result[1]) - instr_handler.set_parameter( - instr, - {param: self.accumulation_voltages[i]}, - wait=True - ) + else: - self.SET_current_check(minimum_current, maximum_current) + logger.info(""" + Please check the types of your gates in the config file. The allowed types are: + Dot Ohmic, Sensor Ohmic, Dot Screening, Sensor Screening, Central Screening, + Dot Barrier, Sensor Barrier, Dot Accumulation, Sensor Accumulation, Dot Plunger, Sensor Plunger + """) + + return None + + logger.info(f"{pinch_off_voltages}") + logger.info(f"{saturation_voltages}") - else: - pass + # Here, we have a check to see if any of the Pinch-Offs above failed, if so, we indicate that the Pinch-Off stage has failed - def barrier_barrier_sweep(self, lower_voltages, upper_voltages, num_points): + # For this specific device, the accumulation gates do not Pinch-Off, so we will be exluding them. For now, we will exclude this step. - # First, we gather the lower voltages to which we set our barriers + # Now, we set the voltages on these gates to the the saturation voltages - barrier_dacs_and_vals = {} + saturation_voltages = [1.225, 1.225, 1.225, 1.225, 1.15, 1.15, 1.15, 1.15] + + pinch_off_dacs_and_vals = {} for i in self.gates_to_dacs: - if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + if self.device_gates[i]['type'] not in excluded_types: p = self.device_gates[i]['channel'] - instr, param = p.parameter.split('.', 1) + instr, param = p.split('.', 1) - barrier_dacs_and_vals[i] = gui.instrument_handler.get_parameter( + pinch_off_dacs_and_vals[i] = self.instrument_handler.get_parameter( instr, param, wait=True ) - print(barrier_dacs_and_vals) + pinch_off_targets = [] - barrier_targets = [] + endpoint_iter = iter(saturation_voltages) - for i in self.gates_to_dacs: - - if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + for gate, dac_and_val in pinch_off_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): - p = self.device_gates[i]['channel'] + p = "spi_rack." + dac + + logger.info(f"{p}") + + end_val = next(endpoint_iter) - instr, param = p.parameter.split('.', 1) + logger.info(f"{end_val}") + + if end_val == None: + continue sparam = SweepParam( - parameter = param, - start = barrier_dacs_and_vals[i], - end = upper_voltages[i] + parameter = p, + start = starting_val, + end = end_val ) - barrier_targets.append(sparam) - - print(barrier_targets) + logger.info(f"{sparam}") + + pinch_off_targets.append(sparam) sweep_layer = SweepLayer( - targets = barrier_targets, + targets = pinch_off_targets, num_points = num_points, measurement_time = 0.05 ) - logger.info("Setting Initial Barrier Voltages...") + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) - future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + sweep = Sweep([sweep_layer], measure) - logger.info("Initial Barrier Voltages Set!") + logger.info("Setting Saturation Voltages...") - # Now, we create the sweep parameters for all the gates + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) - gate_targets_dots = [] + logger.info("Saturation Voltages Set!") - gate_targets_sensors = [] + self.SET_current_check(minimum_current = 2, maximum_current = 4) + + # Now that the non-barrier finger gates are set to lower voltages, we perform pinch-offs of the barrier gates for both sides + + included_types = ["Dot Barrier", "Sensor Barrier"] + + barrier_pinch_off_voltages = [] + barrier_saturation_voltages = [] for i in self.gates_to_dacs: - if self.device_gates[i]['type'] == "Dot Barrier": + if self.device_gates[i]['type'] in included_types: - p = self.device_gates[i]['channel'] + if self.device_gates[i]['type'].startswith('Dot'): - instr, param = p.parameter.split('.', 1) + result = self.pinch_off_iterative(gate_voltage = gate_voltage, + final_voltage = triple_dot_turn_on, + gate_name = self.device_gates[i]['label'], + gate_type = self.device_gates[i]['type'], + channel = self.device_gates[i]['channel'], + num_points = num_points) - sparam = SweepParam( - parameter = param, - start = upper_voltages[i], - end = lower_voltages[i] - ) + barrier_pinch_off_voltages.append(result[0]) + barrier_saturation_voltages.append(result[1]) - gate_targets_dots.append(param) + elif self.device_gates[i]['type'].startswith('Sensor'): - elif self.device_gates[i]['type'] == "Sensor Barrier": + result = self.pinch_off_iterative(gate_voltage = gate_voltage, + final_voltage = SET_turn_on, + gate_name = self.device_gates[i]['label'], + gate_type = self.device_gates[i]['type'], + channel = self.device_gates[i]['channel'], + num_points = num_points) - p = self.device_gates[i]['channel'] + barrier_pinch_off_voltages.append(result[0]) + barrier_saturation_voltages.append(result[1]) - instr, param = p.parameter.split('.', 1) + logger.info("Pinch-Offs Complete!") - sparam = SweepParam( - parameter = param, - start = upper_voltages[i], - end = lower_voltages[i] - ) + return barrier_pinch_off_voltages, barrier_saturation_voltages - gate_targets_sensors.append(param) + def pinch_off_iterative(self, gate_voltage, final_voltage, gate_name, gate_type, channel, num_points): - print(gate_targets_dots) - print(gate_targets_sensors) + """ + Iteratively sweeps a single gate, stepping the endpoint lower by `step` volts + on each failed attempt, until pinch-off is confirmed or lower_bound is reached. - for first, second in zip(gate_targets_dots, gate_targets_dots[1:]): + Returns a (pinch_off_voltage, saturation_voltage) tuple on success, or (None, None) on failure. + """ - sweep_layer = SweepLayer( - targets = [first, second], - num_points = num_points, - measurement_time = 0.05 + while final_voltage >= 0.0: + + sparam = SweepParam(parameter = channel, + start = gate_voltage, + end = final_voltage ) - logger.info("Dot Barrier-Barrier Scan Starting...") + sweep_layer = SweepLayer(targets = [sparam], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + + ) - future = gui.experiment_handler.do_sweep(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + sweep = Sweep([sweep_layer], measure) - logger.info("Dot Barrier Scan Complete! Finding Set Points...") + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name}_Pinch_Off_{time_str}.csv" + filename2 = f"{gate_name}_Pinch_Off_{time_str}.png" - # Now, we ensure that the charge sensor has an appropriate current level before tuning the barriers + logger.info(f"{gate_name} sweeping to {final_voltage} V...") + + self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename) + + logger.info(f"{gate_name} Pinch-Off Complete! Confirming Pinch-Off...") - self.SET_current_check(1e-9, 5e-9) + pinch_off_measurement = self.measure_noise_floor() + + names = list(self.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'] + ).keys()) + + """ + Now, we compare the means of each measurement. + If the mean measured after Pinch_Off is comparable the noise floor mean, + then we say that the device has Pinched Off. + + """ - for first, second in zip(gate_targets_sensors, gate_targets_sensors[1:]): + noise_floor_idx = None - sweep_layer = SweepLayer( - targets = [first, second], - num_points = num_points, - measurement_time = 0.05 + if gate_type.startswith('Dot'): + + mean = pinch_off_measurement[names[0] + "_mean"] + noise_floor_idx = 0 + + else: + + mean = pinch_off_measurement[names[1] + "_mean"] + noise_floor_idx = 1 + + # The pinched condition translates to the difference in the means being less than 300 pA. + + pinched = abs(mean - abs(self.means[noise_floor_idx])) < 3e-2 + + sparam_return = SweepParam(parameter = channel, + start = final_voltage, + end = gate_voltage) + + sweep_layer_return = SweepLayer(targets = [sparam_return], + num_points = num_points, + measurement_time = 0.1) + + measure_return = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + self.experiment_handler.set_voltage_configuration(sweep = Sweep([sweep_layer_return], measure_return), + instrument_handler = self.instrument_handler) + + logger.info(f"{gate_name} returned to {gate_voltage} V.") - logger.info("Sensor Barrier-Barrier Scan Starting...") + if pinched: - future = gui.experiment_handler.do_sweep(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + logger.info(f"{gate_name} Pinch-Off confirmed! Finding Pinch-Off Window...") - logger.info("Sensor Barrier-Barrier Scan Complete! Finding Working Points...") + filepath = os.path.join(self.directory, filename) + df = pd.read_csv(filepath, delimiter=",", header=None, skiprows=1) - def coulomb_blockade_sweep(self, barrier_voltages, lower_voltage, upper_voltage, num_points): + pinch_off_sweep = df.iloc[:, 0] + data = [df.iloc[:, -2] * self.triple_dot_preamp_sensitivity * 1e9, df.iloc[:, -1] * self.SET_preamp_sensitivity * 1e9] - # First, we set the barriers to the lower voltages + pinch_off_window, fig = extract_pinch_off_curve_ranges( + x_data = pinch_off_sweep, + y_data = data[noise_floor_idx], + noisefloor = self.means[noise_floor_idx], + filepath = self.directory, + filename = filename2 + ) - barrier_dacs_and_vals = {} + logger.info(f"{pinch_off_window}") - for i in self.gates_to_dacs: + return pinch_off_window - if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + else: - p = self.device_gates[i]['channel'] + if final_voltage == 0.0: - instr, param = p.parameter.split('.', 1) + logger.info(f"{gate_name} did not pinch off at {final_voltage} V. Pinch-Off Failed. Returning None...") - barrier_dacs_and_vals[i] = gui.instrument_handler.get_parameter( - instr, - param, - wait=True - ) + return (None, None) - print(barrier_dacs_and_vals) + logger.info(f"{gate_name} did not pinch off at {final_voltage} V. Stepping down by 200 mV...") - barrier_targets = [] + final_voltage -= 0.2 - for i in self.gates_to_dacs: - - if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + # We also need to check if the final voltage has dipped below 0.0. If yes, we set the final voltage to 0.0. - p = self.device_gates[i]['channel'] + if final_voltage < 0.0: - instr, param = p.parameter.split('.', 1) + final_voltage = 0.0 - sparam = SweepParam( - parameter = param, - start = barrier_dacs_and_vals[i], - end = barrier_voltages[i] - ) + logger.info(f"{gate_name} did not pinch off before reaching lower bound 0.0 V. Returning None...") - barrier_targets.append(sparam) - - print(barrier_targets) + return (None, None) - sweep_layer = SweepLayer( - targets = barrier_targets, - num_points = num_points, - measurement_time = 0.05 - ) + def SET_current_check(self, minimum_current, maximum_current): - logger.info("Setting Initial Barrier Voltages...") + # First, we need to read the current and check if it is above or below the current values specified. + + current_level = self.measure_noise_floor() - future = gui.experiment_handler.set_voltage_configuration(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) + names = list(self.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'], + ).keys()) - print(future) + current_means = [] - logger.info("Initial Barrier Voltages Set!") + for i in names: - # Now, we define the sensor plunger sweeps + mean_name = i + "_mean" + mean = current_level[mean_name] * self.SET_preamp_sensitivity * 1e9 - sensor_plunger_targets = [] + current_means.append(mean) + + logger.info(f"{current_means}") + + # Here, we get the current accumulation voltages + + included_types = ['Dot Accumulation', 'Sensor Accumulation'] + + accumulation_voltages = {} for i in self.gates_to_dacs: - - if self.device_gates[i]['type'] == "Sensor Plunger": + + logger.info("For Loop!") + + if self.device_gates[i]['type'] in included_types: p = self.device_gates[i]['channel'] - instr, param = p.parameter.split('.', 1) + instr, param = p.split('.', 1) - sparam = SweepParam( - parameter = param, - start = lower_voltage, - end = upper_voltage - ) + logger.info(f"{p}") - sensor_plunger_targets.append(sparam) - - print(sensor_plunger_targets) + accumulation_voltages[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) - sweep_layer = SweepLayer( - targets = sensor_plunger_targets, - num_points = num_points, - measurement_time = 0.05 - ) + SET_current_level = current_means[1] - logger.info("Charge Sensor Plunger Sweep Starting...") + logger.info(f"Current Level {SET_current_level}. Checking Current Level...") - future = gui.experiment_handler.do_sweep(sweep = sweep_layer, - instrument_handler = gui.instrument_handler) - - print(future) + while SET_current_level > maximum_current or SET_current_level < minimum_current: - logger.info("Ohmic Bias Set!") - - pass + if SET_current_level > maximum_current: - def coulomb_diamonds(): - pass + logger.info("Current too high! Reducing Accumulation Gate Voltages by 1 mV...") -class GlobalChargeTuning(Protocol): + # We reduce the voltages on the accumulation gates by 1 mV - def __init__(self, device_config): - super().__init__(device_config = device_config) + for key, val in accumulation_voltages.items(): + + logger.info("for loop 2!") + + p = self.device_gates[key]['channel'] + + instr, param = p.split('.', 1) + + logger.info(f"p: {p}") + logger.info(f"instr: {instr}, param: {param}") + logger.info(f"val: {val}") + + current_voltage = val[param] + new_voltage = current_voltage - 1e-3 + + logger.info(f"Setting {param} from {current_voltage} to {new_voltage}") + + accumulation_voltages[key][param] = new_voltage + + self.instrument_handler.set_parameter( + instr, + {param: new_voltage}, + wait=True + ) + + # Update the SET current level + + new_current_level = self.measure_noise_floor() + + new_SET_current_name = names[1] + "_mean" + + new_SET_current_level = ( + new_current_level[new_SET_current_name] + * self.SET_preamp_sensitivity + * 1e9 + ) + + SET_current_level = new_SET_current_level + + logger.info(f"New Current Level: {SET_current_level}. Checking Current Level") + + time.sleep(2) + + elif SET_current_level < minimum_current: + + logger.info("Current too low! Increasing Accumulation Gate Voltages by 1 mV...") + + # We increase the voltages on the accumulation gates by 1 mV + + for key, val in accumulation_voltages.items(): + + logger.info("for loop 2!") + + p = self.device_gates[key]['channel'] + + instr, param = p.split('.', 1) + + logger.info(f"p: {p}") + logger.info(f"instr: {instr}, param: {param}") + logger.info(f"val: {val}") + + current_voltage = val[param] + new_voltage = current_voltage + 1e-3 + + logger.info(f"Setting {param} from {current_voltage} to {new_voltage}") + + accumulation_voltages[key][param] = new_voltage + + self.instrument_handler.set_parameter( + instr, + {param: new_voltage}, + wait=True + ) + + # Update the SET current level + + new_current_level = self.measure_noise_floor() + + new_SET_current_name = names[1] + "_mean" + + new_SET_current_level = ( + new_current_level[new_SET_current_name] + * self.SET_preamp_sensitivity + * 1e9 + ) + + SET_current_level = new_SET_current_level + + logger.info(f"New Current Level: {SET_current_level}. Checking Current Level") + + time.sleep(2) + + logger.info(f"SET Current Level: {SET_current_level}") + + def barrier_barrier_sweep(self, lower_voltages, upper_voltages, num_points): + + # First, we gather the upper voltages to which we set our barriers + + barrier_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Dot Barrier" or self.device_gates[i]['type'] == "Sensor Barrier": + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + barrier_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + barrier_targets = [] + + endpoint_iter = iter(upper_voltages) + + for gate, dac_and_val in barrier_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + logger.info(f"{p}") + + end_val = next(endpoint_iter) + + logger.info(f"{end_val}") + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = end_val + ) + + logger.info(f"{sparam}") + + barrier_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = barrier_targets, + num_points = num_points, + measurement_time = 0.05 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Initial Barrier Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Barrier Voltages Set!") + + self.SET_current_check(minimum_current = 2, maximum_current = 4) + + # Now, we create the sweep parameters for all the gates + + gate_targets_dots = [] + + gate_targets_sensors = [] + + gate_return_dots = [] + + dot_barrier_names = [] + + sensor_barrier_names = [] + + # Here, we build the actual barrier-barrier scans + + barrier_idx = 0 + + for item in self.gates_to_dacs: + + if self.device_gates[item]['type'] == "Dot Barrier": + + p = self.device_gates[item]['channel'] + + name = self.device_gates[item]["label"] + + dot_barrier_names.append(name) + + sparam = SweepParam( + parameter = p, + start = upper_voltages[barrier_idx], + end = lower_voltages[barrier_idx] + ) + + gate_targets_dots.append(sparam) + + barrier_idx += 1 + + elif self.device_gates[item]['type'] == "Sensor Barrier": + + p = self.device_gates[item]['channel'] + + name = self.device_gates[item]["label"] + + sensor_barrier_names.append(name) + + sparam = SweepParam( + parameter = p, + start = upper_voltages[barrier_idx], + end = lower_voltages[barrier_idx] + ) + + gate_targets_sensors.append(sparam) + + barrier_idx += 1 + + # Here, we define the return sweeps + + barrier_idx = 0 + + for item in self.gates_to_dacs: + + if self.device_gates[item]['type'] == "Dot Barrier": + + p = self.device_gates[item]['channel'] + + sparam = SweepParam( + parameter = p, + start = lower_voltages[barrier_idx], + end = upper_voltages[barrier_idx] + ) + + gate_return_dots.append(sparam) + + barrier_idx += 1 + + best_points = [] + + for i, (first, second, ret_first, ret_second) in enumerate(zip(gate_targets_dots, + gate_targets_dots[1:], + gate_return_dots, + gate_return_dots[1:] + ) + ): + + sweep_layer_1 = SweepLayer( + targets = [first], + num_points = num_points, + measurement_time = 0.2 + ) + + sweep_layer_2 = SweepLayer( + targets = [second], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer_1, sweep_layer_2], measure) + + logger.info("Dot Barrier-Barrier Scan Starting...") + + gate_name_1 = dot_barrier_names[i] + gate_name_2 = dot_barrier_names[i + 1] + + gates = [gate_name_1, gate_name_2] + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name_1}_{gate_name_2}_Scan_{time_str}.csv" + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename) + + # Here, we find the set points for the dot barrier-barrier scans + + logger.info("Dot Barrier Scan Complete! Finding Set Points...") + + filepath = os.path.join(self.directory, filename) + + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) + + lb_data = df.iloc[:, 0] + rb_data = df.iloc[:, 1] + + current_data = df.iloc[:, -2] + + filename = filename.removesuffix('.csv') + ".png" + + logger.info("file defined...") + + best_point, set_points, perp_traces, fig = extract_working_point(lb_data = lb_data, + rb_data = rb_data, + current_data = current_data, + gates = gates, + DotTuning = "Triple Dot", + barrier_pinch_offs = [lower_voltages[i], lower_voltages[i + 1]], + filepath = self.directory, + filename = filename + ) + + best_points.append(best_point) + + logger.info(f"Best point: {best_point}") + + # Now, we reset the barriers back to their starting voltages + + return_layer = SweepLayer( + targets = [ret_first, ret_second], + num_points = num_points, + measurement_time = 0.05 + ) + + return_sweep = Sweep([return_layer], measure) + + logger.info("Returning Barriers to starting values...") + + future = self.experiment_handler.set_voltage_configuration(sweep = return_sweep, + instrument_handler = self.instrument_handler) + + # Now, we unpack the tuples found into voltages to set to the Dot Barriers + + logger.info(f"Best Points: {best_points}") + + dot_barrier_voltages = [] + + dot_barrier_voltages = [best_points[0][0]] + + for left, right in zip(best_points[:-1], best_points[1:]): + + dot_barrier_voltages.append((left[1] + right[0]) / 2) + + dot_barrier_voltages.append(best_points[-1][1]) + + logger.info(f"Barrier Voltages: {dot_barrier_voltages}") + + # Now, we set the barriers on the dot side to the set points found. + + barrier_targets_dots = [] + + barrier_idx = 0 + + dot_side = None + + for item in self.gates_to_dacs: + + if self.device_gates[item]['type'] == "Dot Barrier": + + dot_side = True + + p = self.device_gates[item]['channel'] + + sparam = SweepParam( + parameter = p, + start = upper_voltages[barrier_idx], + end = dot_barrier_voltages[barrier_idx] + ) + + barrier_targets_dots.append(sparam) + + barrier_idx += 1 + + if dot_side: + + sweep_layer = SweepLayer( + targets = barrier_targets_dots, + num_points = num_points, + measurement_time = 0.05 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Dot Barriers to Set Points...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Barrier Set Points Set!") + + # Now, we ensure that the charge sensor has an appropriate current level before tuning the barriers + + self.SET_current_check(minimum_current = 1, maximum_current = 3) + + for i, (first, second) in enumerate(zip(gate_targets_sensors, + gate_targets_sensors[1:] + ) + ): + + sweep_layer_1 = SweepLayer( + targets = [first], + num_points = num_points, + measurement_time = 0.2 + ) + + sweep_layer_2 = SweepLayer( + targets = [second], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer_1, sweep_layer_2], measure) + + logger.info("Sensor Barrier-Barrier Scan Starting...") + + gate_name_1 = sensor_barrier_names[i] + gate_name_2 = sensor_barrier_names[i + 1] + + gates = [gate_name_1, gate_name_2] + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name_1}_{gate_name_2}_Scan_{time_str}.csv" + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename) + + # Here, we determine the working points for the charge sensor + + logger.info("Sensor Barrier-Barrier Scan Complete! Finding Working Points...") + + filepath = os.path.join(self.directory, filename) + + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) + + lb_data = df.iloc[:, 0] + rb_data = df.iloc[:, 1] + + current_data = df.iloc[:, -1] + + filename = filename.removesuffix('.csv') + ".png" + + best_point, working_points, perp_traces, fig = extract_working_point(lb_data = lb_data, + rb_data = rb_data, + current_data = current_data, + gates = gates, + DotTuning = "SET", + barrier_pinch_offs = [lower_voltages[-i -2], lower_voltages[-i - 1]], + filepath = self.directory, + filename = filename + ) + + logger.info(f"Best: {best_point}") + logger.info(f"{working_points}") + + return best_point + + def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_voltages, num_points): + + # First, we set the sensor_barriers to the their respective voltages + + sensor_barrier_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Barrier": + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + sensor_barrier_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + barrier_targets = [] + + endpoint_itr = iter(sensor_barrier_voltages) + + for gate, dac_and_val in sensor_barrier_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + barrier_val = next(endpoint_itr) + + sparam = SweepParam( + parameter = "spi_rack." + dac, + start = starting_val, + end = barrier_val + ) + + barrier_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = barrier_targets, + num_points = 20, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Sensor Barriers to Working Point...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Sensor Barriers Set!") + + # Now, we set our charge sensor plunger gates to their initial values + + charge_sensor_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + charge_sensor_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + charge_sensor_targets = [] + + endpoint_iter = iter(lower_voltages) + + for gate, dac_and_val in charge_sensor_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + sensor_plunger_val = next(endpoint_iter) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = sensor_plunger_val + ) + + charge_sensor_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = charge_sensor_targets, + num_points = num_points, + measurement_time = 0.05 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Initial Charge Sensor Plunger Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Charge Sensor Plunger Voltages Set!") + + # Now, we define the sensor plunger sweeps + + sensor_plunger_targets = [] + + sensor_plunger_idx = 0 + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + sparam = SweepParam( + parameter = p, + start = lower_voltages[sensor_plunger_idx], + end = upper_voltages[sensor_plunger_idx] + ) + + sensor_plunger_targets.append(sparam) + + sensor_plunger_idx += 1 + + logger.info(f"{sensor_plunger_targets}") + + sweep_layer = SweepLayer( + targets = sensor_plunger_targets, + num_points = num_points, + measurement_time = 0.05 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Charge Sensor Plunger Sweep Starting...") + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Charge Sensor Plunger Sweep Complete! Finding Sensing Point...") + + for i in self.device_gates: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + gate_name = self.device_gates[i]['label'] + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name}_{time_str}.csv" + + filepath = os.path.join(self.directory, filename) + + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) + + plunger_data = df.iloc[:, 0] + + current_data = df.iloc[:, -1] + + filename = filename.removesuffix('.csv') + ".png" + + conductance_points = extract_max_conductance_points(x_data = plunger_data, y_data = current_data) + + logger.info(f"{conductance_points}") + + def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_voltages, upper_plunger_voltages, num_points): + + # First, we set our S/D biases to their lower thresholds + + sd_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Ohmic": + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + sd_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + plunger_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + sd_targets = [] + + lower_ohmic_voltages = [] + upper_ohmic_voltages = [] + + for i, item in enumerate(lower_sd_voltages): + + lower_ohmic_voltage = item / self.voltage_divider_SET + upper_ohmic_voltage = upper_sd_voltages[i] / self.voltage_divider_SET + + lower_ohmic_voltages.append(lower_ohmic_voltage) + upper_ohmic_voltages.append(upper_ohmic_voltage) + + startpoint_itr = iter(lower_ohmic_voltages) + + for gate, dac_and_val in sd_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + sd_val = next(startpoint_itr) + + sparam = SweepParam( + parameter = "spi_rack." + dac, + start = starting_val, + end = sd_val + ) + + sd_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = sd_targets, + num_points = 100, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Sensor Ohmics to Initial Points...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Sensor Ohmics Set!") + + plunger_targets = [] + + startpoint_itr = iter(lower_plunger_voltages) + + for gate, dac_and_val in plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + plunger_val = next(startpoint_itr) + + sparam = SweepParam( + parameter = "spi_rack." + dac, + start = starting_val, + end = plunger_val + ) + + plunger_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = plunger_targets, + num_points = 100, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Sensor Ohmics to Initial Points...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Sensor Ohmics Set!") + + # Now, we make the Coulomb Diamond Sweeps + + sensor_plunger_targets = [] + + sensor_ohmic_targets = [] + + sensor_plunger_idx = 0 + + sensor_ohmic_idx = 0 + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + sparam = SweepParam( + parameter = p, + start = upper_plunger_voltages[sensor_plunger_idx], + end = lower_plunger_voltages[sensor_plunger_idx] + ) + + sensor_plunger_targets.append(sparam) + + sensor_plunger_idx += 1 + + elif self.device_gates[i]['type'] == "Sensor Ohmic": + + p = self.device_gates[i]['channel'] + + sparam = SweepParam( + parameter = p, + start = lower_ohmic_voltages[sensor_ohmic_idx], + end = upper_ohmic_voltages[sensor_ohmic_idx] + ) + + sensor_ohmic_targets.append(sparam) + + sensor_ohmic_idx += 1 + + for i, item in enumerate(sensor_plunger_targets): + + sweep_layer_1 = SweepLayer( + targets = [sensor_ohmic_targets[i]], + num_points = num_points, + measurement_time = 0.2 + ) + + sweep_layer_2 = SweepLayer( + targets = [sensor_plunger_targets[i]], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer_1, sweep_layer_2], measure) + + logger.info("Coulomb Diamond Sweep Starting...") + + logger.info( + f"Ohmic: {sensor_ohmic_targets[i].parameter}, " + f"{sensor_ohmic_targets[i].start} -> {sensor_ohmic_targets[i].end}" + ) + + logger.info( + f"Plunger: {sensor_plunger_targets[i].parameter}, " + f"{sensor_plunger_targets[i].start} -> {sensor_plunger_targets[i].end}" + ) + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Coulomb Diamond Sweep Complete! Finding Diamonds...") + +class GlobalChargeTuning(Protocol): + + def __init__(self, device_config): + super().__init__(device_config = device_config) + + def confirm_charge_transitions(self): + + # First, we create our plunger sweeps + + # Now, we sweep our plungers and read the SET current to determine if we can sense charge transitions + + return + + def tune_lead_dot_tunneling(self): + + # First, we need to + + return - def tune_lead_dot_tunneling(): - pass def plunger_plunger_sweep(): pass @@ -713,7 +1854,7 @@ def __init__(self, device_config): def determine_charge_states(): pass -class FineTuning(Protocol): +class QubitTuning(Protocol): def __init__(self, device_config): super().__init__(device_config = device_config) diff --git a/src/data_analysis.py b/src/data_analysis.py index 993ad2e..b2ebf21 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -48,13 +48,22 @@ from qcodes.parameters import ParameterBase from nicegui import ui +from tunerlog import TunerLog + +logger = TunerLog('Data Analysis') def logarithmic(x, a, b, x0, y0): - """Logarithmic model used for curve fitting. - - Parameters: - x: independent variable array - a, b, x0, y0: fit parameters + + """ + Logarithmic model used for curve fitting. + + Parameters + ----------- + x : np.array + independent variable array + a, b, x0, y0 : float + fit parameters + """ return a * np.log(b*(x-x0)) + y0 @@ -122,7 +131,10 @@ def fit_to_function(x_data, def extract_turn_on_voltage(x_data: np.array, y_data: np.array, noisefloor: float, + filepath: str, + filename: str, plot_results: bool = True): + """Estimate the turn-on voltage from a gate-sweep current curve. This routine baseline-corrects the current, then finds the first @@ -141,13 +153,22 @@ def extract_turn_on_voltage(x_data: np.array, turnon_voltage = 0 turnon_current = 0 + threshold = noisefloor * 10 + for val in y1: - if val > noisefloor: - idx_turnon = np.where(y1 == val)[0][0] # get the index of the turn-on point - turnon_voltage = x1[idx_turnon] - turnon_current = y1[idx_turnon] + + if val > abs(threshold): + idx_turnon = np.where(y1 == val)[0][0] # get the index of the turn-on point + + logger.info(f"Index: {idx_turnon}") + + turnon_voltage = x1[idx_turnon - 1] + + logger.info(f"Turn_On Voltage: {turnon_voltage}") + + turnon_current = y1[idx_turnon - 1] break - + # --- Plot data --- if plot_results: @@ -177,16 +198,23 @@ def extract_turn_on_voltage(x_data: np.array, ax.set_yticklabels([f'{np.abs(yticks_span[0]):.1f}', '', '', '', f'{yticks_span[-1]:.4f}'], fontsize=25) plt.tight_layout() + + filepath = os.path.join(filepath, filename) + + fig.savefig(filepath, dpi = 'figure', bbox_inches='tight') + plt.close(fig) # --- Print summary --- - print(f" Turn-on Voltage: {turnon_voltage:.3f} V") + #print(f" Turn-on Voltage: {turnon_voltage:.3f} V") - return turnon_voltage, fig # Turn-on calculated by thresholding + return turnon_voltage, fig -def pinch_off_curve_ranges(x_data: np.array, +def extract_pinch_off_curve_ranges(x_data: np.array, y_data: np.array, - threshold: float, + noisefloor: float, + filepath: str, + filename: str, debug: bool = False, plot_results: bool = True): """Identify pinch-off and saturation voltage ranges for a sweep. @@ -204,11 +232,6 @@ def pinch_off_curve_ranges(x_data: np.array, if y1[0] < 0: y1 = -y1 - # --- Check if we are pinch-offed --- - - if y1[0] < threshold: - raise ValueError("Current at the end of the sweep is above the pinch-off noisefloor, indicating the device may not be fully pinch-offed. Please check the data or adjust the threshold.") - # --- Finding Pinch-off Voltage --- start_idx = int(np.argmin(np.abs(x1))) @@ -405,13 +428,17 @@ def pinch_off_curve_ranges(x_data: np.array, ax.set_ylim(ax.get_ylim()[0], ax.get_ylim()[1]) plt.tight_layout() + + filepath = os.path.join(filepath, filename) + fig.savefig(filepath, dpi = 'figure', bbox_inches='tight') + plt.close(fig) # --- Print summary --- - print(f" Saturation Voltage: {sat_voltage:.3f} V") + """print(f" Saturation Voltage: {sat_voltage:.3f} V") print(f" Midpoint Voltage: {V0:.3f} V") - print(f" Pinch-off Voltage: {pinch_off_voltage:.3f} V\n") + print(f" Pinch-off Voltage: {pinch_off_voltage:.3f} V\n") """ voltage_window = (pinch_off_voltage, sat_voltage) @@ -556,14 +583,18 @@ def extract_working_point(lb_data: np.array, current_data: np.array, gates: list[str], DotTuning: str, - barrier_pinch_offs: list[float], + barrier_pinch_offs: list[float], + filepath: str, + filename: str, minAngleDeg: float = -60, maxAngleDeg: float = -30, - minLineLength: int = 60, + minLineLength: int = 50, maxLineGap: int = 200, debug: bool = False, - plot_results: bool = True) -> list[tuple]: - """Find working-point lines in a 2D barrier sweep image. + plot_results: bool = True): + + """ + Find working-point lines in a 2D barrier sweep image. This function converts raw barrier voltage and current data into an image, applies ridge detection and Hough transform filtering, and returns the @@ -624,8 +655,9 @@ def extract_working_point(lb_data: np.array, lb_voltages = np.linspace(lb_data.min(), lb_data.max(), nx) rb_voltages = np.linspace(rb_data.min(), rb_data.max(), ny) - + logger.info("calculation starting...") + # ---------- Gradient Calculation and Ridge Detection ---------- @@ -691,6 +723,8 @@ def extract_working_point(lb_data: np.array, hough_length = max(12, int(minLineLength * 0.15)) hough_gap = max(1, int(maxLineGap * 0.03)) + logger.info("hough lines drawing...") + lines = transform.probabilistic_hough_line( ridge_masked, threshold=hough_threshold, @@ -743,6 +777,8 @@ def extract_working_point(lb_data: np.array, line_candidates.sort(key=lambda item: -item[0]) filtered_lines = [entry[1] for entry in line_candidates] + logger.info("filtering...") + if not filtered_lines: # Relax angle range slightly if no good long line was found. for p0, p1 in lines: @@ -810,6 +846,8 @@ def extract_working_point(lb_data: np.array, # Now, for each filtered line, we define a line perpendicular to it, then find the peaks in current along them + logger.info("finding traces...") + for x1, y1, x2, y2 in filtered_lines: # midpoints @@ -922,6 +960,11 @@ def extract_working_point(lb_data: np.array, # First, we sort the points in order of increasing current + logger.info("selecting bias points...") + + logger.info(f"filtered_lines: {len(filtered_lines)}") + logger.info(f"perp_candidates: {len(perp_candidates)}") + perp_candidates.sort(key=lambda x: -x[0]) # Then, we pick the top 4 points of highest current @@ -945,6 +988,8 @@ def extract_working_point(lb_data: np.array, for (_, vx, vy, _, _, _) in top_candidates ] + logger.info("selecting working points...") + dist_to_pinch_off_corner = {} # Compute selected working points. For Triple Dot, shift each point 0.1 V in the @@ -974,6 +1019,8 @@ def extract_working_point(lb_data: np.array, dist_to_pinch_off_corner[tuple(cand_point)] = np.linalg.norm(cand_point - barrier_pinch_offs) + logger.info("defining perp traces...") + perp_traces_for_plot = [ tr for tr in perp_traces_for_plot if tr["trace_id"] in selected_trace_ids @@ -982,6 +1029,9 @@ def extract_working_point(lb_data: np.array, # Now, we overlay perpendicular traces (strictly clipped to BL quadrant) for tr in perp_traces_for_plot: + + logger.info("for loop!") + vx = np.interp(tr["px"], x_index_arr, lb_voltages) vy = np.interp(tr["py"], y_index_arr, rb_voltages) @@ -992,10 +1042,14 @@ def extract_working_point(lb_data: np.array, if not np.any(in_quad): continue + logger.info("finding index!") + idx = np.where(in_quad)[0] splits = np.where(np.diff(idx) > 1)[0] blocks = np.split(idx, splits + 1) + logger.info("getting peak!") + peak_idx = tr.get("peak_idx", None) chosen_block = None if peak_idx is not None: @@ -1013,10 +1067,17 @@ def extract_working_point(lb_data: np.array, tr["chosen_block"] = chosen_block + logger.info("finding closest candidate...") + + logger.info(f"top_candidates: {len(top_candidates)}") + logger.info(f"dist_to_pinch_off_corner: {len(dist_to_pinch_off_corner)}") + closest_candidate, closest_value = min(dist_to_pinch_off_corner.items(), key=lambda kv: kv[1]) # ---------- Final Plotting ---------- + logger.info("Plotting Results...") + if plot_results: # Create figure and axes @@ -1283,6 +1344,11 @@ def round_to_step(x, step): return step * np.round(x / step) # ax.set_xlim(new_xmin, new_xmax) # ax.set_ylim(new_ymin, new_ymax) + filepath = os.path.join(filepath, filename) + fig.savefig(filepath, dpi = 'figure', bbox_inches='tight') + + logger.info("Figure saved!") + plt.close(fig) # These are 1D perpendicular trace plots @@ -1867,6 +1933,8 @@ def round_to_step(x, step): return step * np.round(x / step) plt.title("Hough Transform Lines from Filtered Ridges") plt.show() + logger.info("Returning...") + if DotTuning == 'Triple Dot': return best_shifted_point, shifted_points, perp_traces_for_plot, fig elif DotTuning == 'SET': diff --git a/src/experiment_base.py b/src/experiment_base.py index f819128..40364fe 100644 --- a/src/experiment_base.py +++ b/src/experiment_base.py @@ -51,15 +51,12 @@ def __init__(self, layers, measure): for p in layer.targets ] - self.filename = f"sweep_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" - - desktop = os.path.join(os.path.expanduser("~"), "Desktop") - self.csv_path = os.path.join(desktop, self.filename) + self.directory = os.path.join(os.path.expanduser("~"), r"C:\Users\BaughLaflamme\Desktop\3d1s_W151_1 Measurements\3D1S_w151_1 - Autotuning Tests") self._csv_file = None self._csv_writer = None - def _open_csv(self): + def _open_csv(self, filename): keys = [ 'agilent_left.volt', @@ -68,9 +65,13 @@ def _open_csv(self): ap = list(self.all_params) self._header = ap + keys - + + self.filename = filename + + self.csv_path = os.path.join(self.directory, self.filename) + self._csv_file = open(self.csv_path, "w", newline="") - + self._csv_writer = csv.writer(self._csv_file) self._csv_writer.writerow(self._header) @@ -89,7 +90,7 @@ def set_voltage_configuration(self, instr_handler, abort_event, current_setpoint ) finally: - print("Voltage Configuration Set!") + print() def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): @@ -162,10 +163,11 @@ def set_voltage_layer(self, idx, instr_handler, abort_event, current_setpoints): new_setpoints = current_setpoints.copy() new_setpoints.update(step_values) - def run(self, instr_handler, abort_event, current_setpoints = {}): + def run(self, instr_handler, abort_event, filename, current_setpoints = {}): try: - self._open_csv() + + self._open_csv(filename = filename) self._run_layer( 0, @@ -177,6 +179,8 @@ def run(self, instr_handler, abort_event, current_setpoints = {}): finally: self._close_csv() + logger.info("Data Recorded!") + def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): if idx == len(self.layers): @@ -258,20 +262,28 @@ def _run_layer(self, idx, instr_handler, abort_event, current_setpoints): if idx < len(self.layers) - 1 and i < layer.num_points - 1: + reset_layer = self.layers[idx - 1] + + logger.info(f"{reset_layer}") + + reset_targets = reset_layer.targets[0] + + reset_start = reset_targets.end + + reset_end = reset_targets.start + + logger.info(f"start: {reset_start}, end: {reset_end}") + reset_layer = self._build_reset_layers( idx, - p.end, - p.start, + reset_start, + reset_end, num_points=50 ) - print(f"reset_layers: {reset_layer}") - # Save original layers original_layers = self.layers - print(f"original_layers: {original_layers}") - try: # Swap in reset layers self.layers = reset_layer @@ -329,4 +341,9 @@ def _build_reset_layers(self, idx, start_setpoints, end_setpoints, num_points=10 reset_layer.append(new_layer) + logger.info( + f"RESET: {new_p.parameter} " + f"{new_p.start} -> {new_p.end}" + ) + return reset_layer \ No newline at end of file diff --git a/src/experiment_handler.py b/src/experiment_handler.py index 1e53007..7d280ae 100644 --- a/src/experiment_handler.py +++ b/src/experiment_handler.py @@ -68,7 +68,6 @@ class ExperimentThread: def __init__(self): - self.job_event = threading.Event() self.abort_event = threading.Event() self.shutdown_event = threading.Event() @@ -81,6 +80,7 @@ def run(self): self.thread.start() def join(self): + print("Stopping the experiment thread...") self.shutdown_event.set() self.thread.join() @@ -95,6 +95,23 @@ def add_job(self, priority: int = 1, wait: bool = True, timeout: float = None): + + future = TunerFuture() + self.job_queue.put((priority, (f, args, future))) + + self.job_event.set() + + if wait: + return future.result(timeout) + + return future + + def add_job_old(self, + f: Callable, + args: tuple = (), + priority: int = 1, + wait: bool = True, + timeout: float = None): future = TunerFuture() @@ -113,7 +130,25 @@ def abort(self): self.abort_event.set() def __thread_loop__(self): - print("Starting the Experiment Thread Worker") + + print("Starting worker") + + while not self.shutdown_event.is_set(): + + priority, (f, args, future) = self.job_queue.get() + + try: + result = f(*args, self.abort_event) + + except Exception as e: + future.set_exception(e) + + else: + future.set_result(result) + + self.job_queue.task_done() + + def __thread_loop__old(self): while not self.shutdown_event.is_set(): @@ -147,7 +182,6 @@ def __thread_loop__(self): # reset event once queue is empty self.job_event.clear() - class experiment_handler: def __init__(self, experiment_thread): @@ -156,52 +190,39 @@ def __init__(self, experiment_thread): def do_sweep(self, sweep, instrument_handler, + filename, current_setpoints = {}, wait: bool = True, - timeout: float = 60): + timeout: float = 60000): - future = TunerFuture() + logger.info("Sweep Start!") def sweep_fn(abort_event): - result = sweep.run(instrument_handler, abort_event, current_setpoints) + result = sweep.run(instrument_handler, abort_event, filename, current_setpoints) - future.set_result(result) return result - self.experiment_thread.add_job( - sweep_fn, - args=(), - wait=False - ) - - if wait: - return future.result(timeout) - else: - return future + return self.experiment_thread.add_job( + sweep_fn, + args=(), + wait=wait, + timeout=timeout + ) def set_voltage_configuration(self, sweep, instrument_handler, current_setpoints = {}, wait: bool = True, - timeout: float = 60, - filename: str = "sweep.csv"): - - future = TunerFuture() + timeout: float = 60000): def sweep_fn(abort_event): result = sweep.set_voltage_configuration(instrument_handler, abort_event, current_setpoints) - - future.set_result(result) return result - self.experiment_thread.add_job( - sweep_fn, - args=(), - wait=False - ) - - if wait: - return future.result(timeout) - else: - return future \ No newline at end of file + return self.experiment_thread.add_job( + sweep_fn, + args=(), + wait=wait, + timeout=timeout + ) \ No newline at end of file diff --git a/src/gui.py b/src/gui.py index 59170b2..af9a5c0 100644 --- a/src/gui.py +++ b/src/gui.py @@ -18,6 +18,7 @@ from instrument_handler import create_buffer_instance import time from experiment_handler import get_experiment_handler +from autotuning_handler import get_autotuning_handler from qcodes.station import Station from qcodes.instrument_drivers.mock_instruments import DummyInstrument from qcodes.instrument import Instrument @@ -26,7 +27,10 @@ import os, sys from tunerlog import TunerLog from experiment_base import SweepParam, SweepLayer, Sweep -from autotuning_protocol import Protocol, Bootstrapping, GlobalChargeTuning, VirtualGating, ChargeStateTuning +from autotuning_protocol import Protocol +from tunerlog import TunerLog + +logger = TunerLog('GUI') class RandomDummy(DummyInstrument): @@ -67,12 +71,13 @@ def __init__(self): self.logger = TunerLog("TunerGUI") self.start_time = time.monotonic() - self.station = Station(config_file = "../configs/Intel_Config_Test.yaml") + self.station = Station(config_file = "../configs/test_station.yaml") self.station_lock = threading.Lock() self.instrument_handler = create_buffer_instance(self.station, self.station_lock) self.experiment_handler = get_experiment_handler() + self.autotuning_handler = get_autotuning_handler() def init_agilent(instrument: Instrument, *args): instrument.NPLC(1.0) @@ -82,8 +87,6 @@ def init_spi_rack(instrument: Instrument, *args): instrument.add_spi_module(8, 'D5a', 'module1') instrument.add_spi_module(7, 'D5a', 'module2') - args[0].instrument_snapshot(instrument.module1.dac0) - instrument.module2.dac14(0.01) return self.instrument_handler.add_instrument("agilent_left", init_agilent) @@ -94,7 +97,6 @@ def init_spi_rack(instrument: Instrument, *args): self.instrument_handler.monitor_parameter('agilent_right', ['volt']) self.abort_signal = threading.Event() - # The below methods define the layout of the GUI @@ -166,20 +168,20 @@ def root_page(self): ui.label('Debug / Manual Controls') - ui.button( + """ ui.button( 'Run Test Sweep', on_click=self.run_test_sweep - ) + ) """ ui.button( 'Run Test Sweep 2', on_click=self.run_test_sweep_2 ) - ui.button( + """ ui.button( 'Run Test Sweep 3', on_click=self.run_test_sweep_3 - ) + ) """ ui.button( 'Run Bootstrapping', @@ -225,8 +227,7 @@ def run_test_sweep(self): ih.read_buffer([ 'agilent_left.volt', 'agilent_right.volt' - ]), - ['agilent_left.volt', 'agilent_right.volt'] + ]) ) ) @@ -260,21 +261,21 @@ def run_test_sweep_2(self): layers=[ SweepLayer( targets=[ - SweepParam('spi_rack.module1.dac2.voltage', 0.0, 0.3) + SweepParam('spi_rack.module2.dac13.voltage', 0.0, 0.3) ], num_points=20, measurement_time=0.05 ), SweepLayer( targets=[ - SweepParam('spi_rack.module1.dac1.voltage', 0.0, 0.3) + SweepParam('spi_rack.module2.dac15.voltage', 0.0, 0.3) ], num_points=20, measurement_time=0.05 ), SweepLayer( targets=[ - SweepParam('spi_rack.module1.dac0.voltage', 0.0, 0.3) + SweepParam('spi_rack.module2.dac14.voltage', 0.0, 0.3) ], num_points=20, measurement_time=0.05 @@ -364,14 +365,11 @@ def run_bootstrapping(self): self.debug_status.set_text("Running Bootstrapping...") self.logger.info("Bootstrapping Jobs queued") - protocol = Protocol(device_config = r"C:\Users\bennt\OneDrive\Documents\GitHub\QuantumDotControl\configs\Intel_Config_Test.yaml") - - self.logger.info("Protocol Object Created") - - bootstrapping = Bootstrapping(device_config = r"C:\Users\bennt\OneDrive\Documents\GitHub\QuantumDotControl\configs\Intel_Config_Test.yaml") - - self.logger.info("Bootstrapping Completed!") - + future = self.autotuning_handler.run_bootstrapping(device_config = r'C:\Users\BaughLaflamme\Documents\GitHub\QuantumDotControl\configs\Intel_Config.yaml', + instrument_handler = self.instrument_handler, + experiment_handler = self.experiment_handler, + wait = False + ) def header(self): @@ -475,7 +473,7 @@ def update_liveplot(self): self.lines[j].set_ydata(data) self.lines[j].set_xdata(times_offset) self.ax.set_xlim(min(times_offset), max(times_offset)) - self.ax.set_ylim(-0.5, 5.0) + self.ax.set_ylim(-0.5, 2.0) self.ax.legend(self.lines, keys, ) self.liveplot.update() @@ -546,6 +544,7 @@ def on_autotune(self): def watchdog_timer(self): if not self.instrument_handler.watchdog(): + logger.info("Watchdog Failed!") # Trigger a reset self.logger.error("Readout buffer watchdog detected a problem. Triggering a reset.") #python = sys.executable # path to the Python interpreter diff --git a/src/instrument_handler.py b/src/instrument_handler.py index 85790ac..e0889e1 100644 --- a/src/instrument_handler.py +++ b/src/instrument_handler.py @@ -70,6 +70,7 @@ def result(self, timeout : float | None = None): raise self._exception return self._result else: + logger.info("Timeout Reached!") raise TimeoutError("Future timed out while waiting for result") @dataclass @@ -272,7 +273,7 @@ def _process_queue(self): while self.job_queue.qsize() > 0 and count > 0: curr_time = time.monotonic() job = self.job_queue.get() - logger.debug("Instrument '%s', processing job %r", self.instrument.name, job) + #logger.debug("Instrument '%s', processing job %r", self.instrument.name, job) if job.when > curr_time and (job.when > 0): self.job_queue.put(job) diff --git a/src/logger.py b/src/logger.py index 61310d6..c5bbae8 100644 --- a/src/logger.py +++ b/src/logger.py @@ -21,12 +21,6 @@ from qcodes.parameters import ParameterBase import numpy.typing as npt -import skimage -from skimage.transform import probabilistic_hough_line -from skimage.feature import canny -from skimage.filters import threshold_otsu -from skimage.morphology import diamond, rectangle # noqa - import logging from colorlog import ColoredFormatter import sys diff --git a/src/main.py b/src/main.py index 59ee314..9b2dead 100644 --- a/src/main.py +++ b/src/main.py @@ -17,12 +17,14 @@ def start_tuner_gui(): global gui, logger + print("Starting Program") + logger = TunerLog("main") logger.info("Starting GUI...") gui = tuner_gui() - print("Gui Startup Complete! Welcome to the Quantum Device Autotuner!") + print("Gui Startup Complete! Welcome to QAT!") @app.on_shutdown def stop_tuner_gui(): diff --git a/src/tunerlog.py b/src/tunerlog.py index bf4abbf..f81f6fe 100644 --- a/src/tunerlog.py +++ b/src/tunerlog.py @@ -40,8 +40,12 @@ def emit(self, record: logging.LogRecord) -> None: level = record.levelno colors = {logging.DEBUG: "text-blue text-sm italic", logging.INFO: "text-sm", logging.WARNING: "text-yellow-600 text-sm", logging.ERROR: "text-red", logging.CRITICAL: "text-red font-bold underline"} msg = self.format(record) + + client = self.element.client + print("CONNECT", client.id, hex(id(client))) self.element.push(msg, classes=colors[level]) - except Exception: + except Exception as e: + print("UI handler failed:", repr(e)) self.handleError(record) class TunerLog(logging.Logger): From fd5ebe25559f039fcb0ae419e4e95b35ad5a2734 Mon Sep 17 00:00:00 2001 From: VanOschB <118694763+VanOschB@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:56:58 -0400 Subject: [PATCH 30/36] Quick Switch to no iteration Removed iterative pinch-off; this reduces time and allows for the pinch-off curves to be better fit to sigmoidal curves. Also updated the coulomb blockade sweep and diamond functions. --- src/autotuning_protocol.py | 371 +++++++++++++++++++++---------------- 1 file changed, 216 insertions(+), 155 deletions(-) diff --git a/src/autotuning_protocol.py b/src/autotuning_protocol.py index fee96fc..7ed7a36 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -599,24 +599,26 @@ def pinch_off(self, gate_voltage, final_voltages, num_points): if self.device_gates[i]['type'].startswith('Dot'): - result = self.pinch_off_iterative(gate_voltage = gate_voltage, - final_voltage = triple_dot_turn_on, - gate_name = self.device_gates[i]['label'], - gate_type = self.device_gates[i]['type'], - channel = self.device_gates[i]['channel'], - num_points = num_points) + result = self.pinch_off_individual(gate_voltage = gate_voltage, + final_voltage = 0.0, + gate_name = self.device_gates[i]['label'], + gate_type = self.device_gates[i]['type'], + channel = self.device_gates[i]['channel'], + num_points = num_points + ) pinch_off_voltages.append(result[0]) saturation_voltages.append(result[1]) elif self.device_gates[i]['type'].startswith('Sensor'): - result = self.pinch_off_iterative(gate_voltage = gate_voltage, - final_voltage = SET_turn_on, - gate_name = self.device_gates[i]['label'], - gate_type = self.device_gates[i]['type'], - channel = self.device_gates[i]['channel'], - num_points = num_points) + result = self.pinch_off_individual(gate_voltage = gate_voltage, + final_voltage = 0.0, + gate_name = self.device_gates[i]['label'], + gate_type = self.device_gates[i]['type'], + channel = self.device_gates[i]['channel'], + num_points = num_points + ) pinch_off_voltages.append(result[0]) saturation_voltages.append(result[1]) @@ -724,24 +726,26 @@ def pinch_off(self, gate_voltage, final_voltages, num_points): if self.device_gates[i]['type'].startswith('Dot'): - result = self.pinch_off_iterative(gate_voltage = gate_voltage, - final_voltage = triple_dot_turn_on, - gate_name = self.device_gates[i]['label'], - gate_type = self.device_gates[i]['type'], - channel = self.device_gates[i]['channel'], - num_points = num_points) + result = self.pinch_off_individual(gate_voltage = gate_voltage, + final_voltage = 0.0, + gate_name = self.device_gates[i]['label'], + gate_type = self.device_gates[i]['type'], + channel = self.device_gates[i]['channel'], + num_points = num_points + ) barrier_pinch_off_voltages.append(result[0]) barrier_saturation_voltages.append(result[1]) elif self.device_gates[i]['type'].startswith('Sensor'): - result = self.pinch_off_iterative(gate_voltage = gate_voltage, - final_voltage = SET_turn_on, - gate_name = self.device_gates[i]['label'], - gate_type = self.device_gates[i]['type'], - channel = self.device_gates[i]['channel'], - num_points = num_points) + result = self.pinch_off_individual(gate_voltage = gate_voltage, + final_voltage = 0.0, + gate_name = self.device_gates[i]['label'], + gate_type = self.device_gates[i]['type'], + channel = self.device_gates[i]['channel'], + num_points = num_points + ) barrier_pinch_off_voltages.append(result[0]) barrier_saturation_voltages.append(result[1]) @@ -750,144 +754,125 @@ def pinch_off(self, gate_voltage, final_voltages, num_points): return barrier_pinch_off_voltages, barrier_saturation_voltages - def pinch_off_iterative(self, gate_voltage, final_voltage, gate_name, gate_type, channel, num_points): + def pinch_off_individual(self, gate_voltage, final_voltage, gate_name, gate_type, channel, num_points): """ - Iteratively sweeps a single gate, stepping the endpoint lower by `step` volts - on each failed attempt, until pinch-off is confirmed or lower_bound is reached. + Sweeps a single gate to determine pinch-off. Returns a (pinch_off_voltage, saturation_voltage) tuple on success, or (None, None) on failure. """ - while final_voltage >= 0.0: - - sparam = SweepParam(parameter = channel, - start = gate_voltage, - end = final_voltage - ) - - sweep_layer = SweepLayer(targets = [sparam], - num_points = num_points, - measurement_time = 0.2 - ) - - measure = lambda ih, sp: ( - ih.read_buffer([ - 'agilent_left.volt', - 'agilent_right.volt' - ]), - ['agilent_left.volt', 'agilent_right.volt'] - - ) - - sweep = Sweep([sweep_layer], measure) - - time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"{gate_name}_Pinch_Off_{time_str}.csv" - filename2 = f"{gate_name}_Pinch_Off_{time_str}.png" + sparam = SweepParam(parameter = channel, + start = gate_voltage, + end = final_voltage + ) - logger.info(f"{gate_name} sweeping to {final_voltage} V...") + sweep_layer = SweepLayer(targets = [sparam], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + + ) - self.experiment_handler.do_sweep(sweep = sweep, - instrument_handler = self.instrument_handler, - filename = filename) - - logger.info(f"{gate_name} Pinch-Off Complete! Confirming Pinch-Off...") + sweep = Sweep([sweep_layer], measure) - pinch_off_measurement = self.measure_noise_floor() - - names = list(self.instrument_handler.read_buffer( - ['agilent_left.volt', 'agilent_right.volt'] - ).keys()) + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name}_Pinch_Off_{time_str}.csv" + filename2 = f"{gate_name}_Pinch_Off_{time_str}.png" - """ - Now, we compare the means of each measurement. - If the mean measured after Pinch_Off is comparable the noise floor mean, - then we say that the device has Pinched Off. - - """ + logger.info(f"{gate_name} sweeping to {final_voltage} V...") - noise_floor_idx = None + self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename) + + logger.info(f"{gate_name} Pinch-Off Complete! Confirming Pinch-Off...") - if gate_type.startswith('Dot'): + pinch_off_measurement = self.measure_noise_floor() + + names = list(self.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'] + ).keys()) - mean = pinch_off_measurement[names[0] + "_mean"] - noise_floor_idx = 0 - - else: - - mean = pinch_off_measurement[names[1] + "_mean"] - noise_floor_idx = 1 + """ + Now, we compare the means of each measurement. + If the mean measured after Pinch_Off is comparable the noise floor mean, + then we say that the device has Pinched Off. + + """ - # The pinched condition translates to the difference in the means being less than 300 pA. + noise_floor_idx = None - pinched = abs(mean - abs(self.means[noise_floor_idx])) < 3e-2 + if gate_type.startswith('Dot'): - sparam_return = SweepParam(parameter = channel, - start = final_voltage, - end = gate_voltage) - - sweep_layer_return = SweepLayer(targets = [sparam_return], - num_points = num_points, - measurement_time = 0.1) - - measure_return = lambda ih, sp: ( - ih.read_buffer([ - 'agilent_left.volt', - 'agilent_right.volt' - ]), - ['agilent_left.volt', 'agilent_right.volt'] - - ) - - self.experiment_handler.set_voltage_configuration(sweep = Sweep([sweep_layer_return], measure_return), - instrument_handler = self.instrument_handler) + mean = pinch_off_measurement[names[0] + "_mean"] + noise_floor_idx = 0 + + else: - logger.info(f"{gate_name} returned to {gate_voltage} V.") - - if pinched: + mean = pinch_off_measurement[names[1] + "_mean"] + noise_floor_idx = 1 - logger.info(f"{gate_name} Pinch-Off confirmed! Finding Pinch-Off Window...") - - filepath = os.path.join(self.directory, filename) - df = pd.read_csv(filepath, delimiter=",", header=None, skiprows=1) + # The pinched condition translates to the difference in the means being less than 300 pA. - pinch_off_sweep = df.iloc[:, 0] - data = [df.iloc[:, -2] * self.triple_dot_preamp_sensitivity * 1e9, df.iloc[:, -1] * self.SET_preamp_sensitivity * 1e9] + pinched = abs(mean - abs(self.means[noise_floor_idx])) < 3e-2 - pinch_off_window, fig = extract_pinch_off_curve_ranges( - x_data = pinch_off_sweep, - y_data = data[noise_floor_idx], - noisefloor = self.means[noise_floor_idx], - filepath = self.directory, - filename = filename2 - ) - - logger.info(f"{pinch_off_window}") - - return pinch_off_window - - else: + sparam_return = SweepParam(parameter = channel, + start = final_voltage, + end = gate_voltage) + + sweep_layer_return = SweepLayer(targets = [sparam_return], + num_points = num_points, + measurement_time = 0.1) + + measure_return = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + + ) + + self.experiment_handler.set_voltage_configuration(sweep = Sweep([sweep_layer_return], measure_return), + instrument_handler = self.instrument_handler) + + logger.info(f"{gate_name} returned to {gate_voltage} V.") - if final_voltage == 0.0: + if pinched: - logger.info(f"{gate_name} did not pinch off at {final_voltage} V. Pinch-Off Failed. Returning None...") + logger.info(f"{gate_name} Pinch-Off confirmed! Finding Pinch-Off Window...") - return (None, None) + filepath = os.path.join(self.directory, filename) + df = pd.read_csv(filepath, delimiter=",", header=None, skiprows=1) - logger.info(f"{gate_name} did not pinch off at {final_voltage} V. Stepping down by 200 mV...") + pinch_off_sweep = df.iloc[:, 0] + data = [df.iloc[:, -2] * self.triple_dot_preamp_sensitivity * 1e9, df.iloc[:, -1] * self.SET_preamp_sensitivity * 1e9] - final_voltage -= 0.2 + pinch_off_window, fig = extract_pinch_off_curve_ranges( + x_data = pinch_off_sweep, + y_data = data[noise_floor_idx], + noisefloor = self.means[noise_floor_idx], + filepath = self.directory, + filename = filename2 + ) - # We also need to check if the final voltage has dipped below 0.0. If yes, we set the final voltage to 0.0. + logger.info(f"{pinch_off_window}") - if final_voltage < 0.0: + return pinch_off_window - final_voltage = 0.0 + else: - logger.info(f"{gate_name} did not pinch off before reaching lower bound 0.0 V. Returning None...") + logger.info(f"{gate_name} did not pinch off at {final_voltage} V. Pinch-Off Failed. Returning None...") - return (None, None) + return (None, None) def SET_current_check(self, minimum_current, maximum_current): @@ -1104,7 +1089,7 @@ def barrier_barrier_sweep(self, lower_voltages, upper_voltages, num_points): logger.info("Setting Initial Barrier Voltages...") future = self.experiment_handler.set_voltage_configuration(sweep = sweep, - instrument_handler = self.instrument_handler) + instrument_handler = self.instrument_handler) logger.info("Initial Barrier Voltages Set!") @@ -1473,7 +1458,7 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ logger.info("Setting Sensor Barriers to Working Point...") future = self.experiment_handler.set_voltage_configuration(sweep = sweep, - instrument_handler = self.instrument_handler) + instrument_handler = self.instrument_handler) logger.info("Sensor Barriers Set!") @@ -1533,7 +1518,7 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ logger.info("Setting Initial Charge Sensor Plunger Voltages...") future = self.experiment_handler.set_voltage_configuration(sweep = sweep, - instrument_handler = self.instrument_handler) + instrument_handler = self.instrument_handler) logger.info("Initial Charge Sensor Plunger Voltages Set!") @@ -1561,26 +1546,41 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ logger.info(f"{sensor_plunger_targets}") - sweep_layer = SweepLayer( - targets = sensor_plunger_targets, - num_points = num_points, - measurement_time = 0.05 - ) + sensor_plunger_idx = 0 - measure = lambda ih, sp: ( - ih.read_buffer([ - 'agilent_left.volt', - 'agilent_right.volt' - ]), - ['agilent_left.volt', 'agilent_right.volt'] - ) + for i in self.gates_to_dacs: - sweep = Sweep([sweep_layer], measure) + if self.device_gates[i]['type'] == 'Sensor Plunger': + + sweep_layer = SweepLayer( + targets = sensor_plunger_targets[sensor_plunger_idx], + num_points = num_points, + measurement_time = 0.05 + ) - logger.info("Charge Sensor Plunger Sweep Starting...") + sensor_plunger_idx += 1 - future = self.experiment_handler.do_sweep(sweep = sweep, - instrument_handler = self.instrument_handler) + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Charge Sensor Plunger Sweep Starting...") + + gate_name = self.device_gates[i]['label'] + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name}_Sweep_{time_str}.csv" + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename + ) logger.info("Charge Sensor Plunger Sweep Complete! Finding Sensing Point...") @@ -1609,7 +1609,7 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_voltages, upper_plunger_voltages, num_points): - # First, we set our S/D biases to their lower thresholds + # First, we get our S/D biases to their lower thresholds sd_dacs_and_vals = {} @@ -1627,6 +1627,8 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v wait=True ) + # We also get our plunger voltages to set them to their lower voltages + plunger_dacs_and_vals = {} for i in self.gates_to_dacs: @@ -1727,12 +1729,12 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v sweep = Sweep([sweep_layer], measure) - logger.info("Setting Sensor Ohmics to Initial Points...") + logger.info("Setting Sensor Plungers to Initial Points...") future = self.experiment_handler.set_voltage_configuration(sweep = sweep, instrument_handler = self.instrument_handler) - logger.info("Sensor Ohmics Set!") + logger.info("Sensor Plungers Set!") # Now, we make the Coulomb Diamond Sweeps @@ -1820,12 +1822,71 @@ class GlobalChargeTuning(Protocol): def __init__(self, device_config): super().__init__(device_config = device_config) - def confirm_charge_transitions(self): + def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_voltages, num_points): # First, we create our plunger sweeps + dot_plunger_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + dot_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + logger.info(f"{dot_plunger_dacs_and_vals}") + # Now, we sweep our plungers and read the SET current to determine if we can sense charge transitions + sensor_plunger_idx = 0 + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + p = self.device_gates[i]['channel'] + + sparam = SweepParam( + parameter = p, + start = lower_plunger_voltages[sensor_plunger_idx], + end = upper_plunger_voltages[sensor_plunger_idx] + ) + + sweep_layer = SweepLayer( + targets = [sparam], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Sensor Plungers to Initial Points...") + + + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename + ) + + logger.info("Sensor Plungers Set!") + return def tune_lead_dot_tunneling(self): From 4302458f216e2b116ae625d62f03ed08d65f15af Mon Sep 17 00:00:00 2001 From: Drv5MC <116960417+Drv5MC@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:15:34 -0400 Subject: [PATCH 31/36] Updated Pinch-off code Using Gompertz function to model the pinch-off curves in order to find saturation values. --- src/data_analysis.py | 275 ++++++++++++++--------------------- src/data_analysis_test.ipynb | 137 +++++++++++++++-- 2 files changed, 236 insertions(+), 176 deletions(-) diff --git a/src/data_analysis.py b/src/data_analysis.py index b2ebf21..32079be 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -50,7 +50,7 @@ from nicegui import ui from tunerlog import TunerLog -logger = TunerLog('Data Analysis') +# logger = TunerLog('Data Analysis') def logarithmic(x, a, b, x0, y0): @@ -93,6 +93,20 @@ def relu(x, a, x0): """ReLU-style model that is zero below x0 and linear above it.""" return np.maximum(0, a * (x - x0)) +def gompertz(x, a, b, c): + """ + Gompertz model used for curve fitting. Type of sigmoid used for asymmetry. + + Parameters + ----------- + x : np.array + independent variable array + a, b, c : float + fit parameters + + """ + return a * np.exp(-b * np.exp(-c * x)) + def fit_to_function(x_data, y_data, function: Callable, @@ -110,14 +124,14 @@ def fit_to_function(x_data, Returns: params: model parameter names popt: optimized parameter values - pcov: covariance matrix of parameter estimates + perr: Error of parameter estimates (square root of covariances) """ if p0 is None: - popt, pcov = curve_fit(function, x_data, y_data) + popt, pcov = curve_fit(function, x_data, y_data, maxfev=10000) perr = np.sqrt(np.diag(pcov)) else: - popt, pcov = curve_fit(function, x_data, y_data, p0=p0) + popt, pcov = curve_fit(function, x_data, y_data, p0=p0, maxfev=10000) perr = np.sqrt(np.diag(pcov)) params = list(inspect.signature(function).parameters.keys())[1:] @@ -126,7 +140,7 @@ def fit_to_function(x_data, for name, val, err in zip(params, popt, perr): print(f"{name} = {val:.3f} ± {err:.3f}") - return params, popt, pcov + return params, popt, perr def extract_turn_on_voltage(x_data: np.array, y_data: np.array, @@ -175,6 +189,11 @@ def extract_turn_on_voltage(x_data: np.array, fig, ax = plt.subplots(figsize=(8,6)) ax.plot(x1, y1, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') + + filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) + + fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') + ax.scatter(turnon_voltage, turnon_current, color='red', s=100, zorder=5, label='Turn-On Point') ax.legend(fontsize=24, frameon=False, loc='upper left') @@ -199,23 +218,23 @@ def extract_turn_on_voltage(x_data: np.array, plt.tight_layout() - filepath = os.path.join(filepath, filename) + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) - fig.savefig(filepath, dpi = 'figure', bbox_inches='tight') + fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') plt.close(fig) # --- Print summary --- #print(f" Turn-on Voltage: {turnon_voltage:.3f} V") - return turnon_voltage, fig + return turnon_voltage def extract_pinch_off_curve_ranges(x_data: np.array, y_data: np.array, noisefloor: float, + gate_type: str, filepath: str, filename: str, - debug: bool = False, plot_results: bool = True): """Identify pinch-off and saturation voltage ranges for a sweep. @@ -226,14 +245,19 @@ def extract_pinch_off_curve_ranges(x_data: np.array, # --- Data definitions --- + # Converts to numpy array x1 = np.array(x_data) y1 = np.array(y_data) if y1[0] < 0: + # Flips current sign if SD bias was inversed y1 = -y1 + y1_norm = y1/np.max(y1) # Normalizes the data + # --- Finding Pinch-off Voltage --- + # Ensures we scan the data from x-values closest to 0V to values away from it start_idx = int(np.argmin(np.abs(x1))) if start_idx == 0: step = 1 @@ -244,15 +268,15 @@ def extract_pinch_off_curve_ranges(x_data: np.array, right_abs = abs(x1[start_idx + 1]) step = 1 if right_abs >= left_abs else -1 - scan_indices = np.arange(start_idx, len(x1), step) if step > 0 else np.arange(start_idx, -1, -1) + scan_indices = np.arange(start_idx, len(x1), step) if step > 0 else np.arange(start_idx, -1, -1) # mask to scan over x_scan = x1[scan_indices] y_scan = y1[scan_indices] - # 1. Use the first 5% of data points (closest to 0V) to characterize the noise floor + # Selects the first 5% of data points, closest to 0V, to see if there are oscillations from pinch-off baseline_window = max(5, int(0.05 * len(y_scan))) baseline_data = y_scan[:baseline_window] - baseline_mean = np.mean(baseline_data) baseline_std = np.std(baseline_data) + epsilon = 0.3 total_signal_range = np.max(y_scan) - np.min(y_scan) # If the start already exhibits heavy oscillations relative to the total range, @@ -260,8 +284,8 @@ def extract_pinch_off_curve_ranges(x_data: np.array, if baseline_std > 0.02 * total_signal_range: pinch_off_pos = 0 else: - # Otherwise, find where it cleanly breaks away from a quiet noise floor - departure_threshold = baseline_mean + max(3.0 * baseline_std, 0.008 * total_signal_range) + # Otherwise, find where the current goes above the noise floor + departure_threshold = noisefloor + epsilon pinch_off_pos = 0 consecutive_points_needed = 3 for i in range(len(y_scan) - consecutive_points_needed): @@ -272,11 +296,11 @@ def extract_pinch_off_curve_ranges(x_data: np.array, early_rise_threshold = max(3, int(0.05 * len(x_scan))) if pinch_off_pos <= early_rise_threshold or pinch_off_pos >= len(x_scan) - 1: idx_pinch_off = int(scan_indices[0]) - pinch_off_pos = 0 # Sync position + pinch_off_pos = 0 else: idx_pinch_off = int(scan_indices[pinch_off_pos]) - # Local peak adjustment fallback (kept from your original architecture) + # Local peak adjustment fallback if 0 < pinch_off_pos < len(y_scan) - 1: if y_scan[pinch_off_pos] >= y_scan[pinch_off_pos - 1] and y_scan[pinch_off_pos] >= y_scan[pinch_off_pos + 1]: look_ahead = min(len(y_scan), pinch_off_pos + max(3, int(0.05 * len(y_scan)))) @@ -288,92 +312,74 @@ def extract_pinch_off_curve_ranges(x_data: np.array, pinch_off_pos = min_pos idx_pinch_off = int(scan_indices[pinch_off_pos]) + # Finds the voltage and current pinch_off_voltage = x1[idx_pinch_off] - pinch_off_current = y1[idx_pinch_off] - - # --- Finding Saturation Voltage using Smooth Derivatives --- - - after_pinch = y_scan[pinch_off_pos:] - if len(after_pinch) < 5: - idx_sat = int(scan_indices[-1]) - sat_voltage = x1[idx_sat] - sat_current = y1[idx_sat] - sat_threshold = None - sat_plateau_start = None - else: - dx = np.abs(np.diff(x_scan)) - dx_mean = np.mean(dx) if len(dx) > 0 else 1.0 - - window_length = min(15, len(after_pinch) // 3) - if window_length % 2 == 0: - window_length = max(3, window_length - 1) - window_length = max(3, window_length) - - # Compute smooth 1st derivative - y_der = signal.savgol_filter(after_pinch, window_length=window_length, polyorder=2, deriv=1, delta=dx_mean) - - # Characterize terminal tail behavior - tail_size = min(15, len(y_der) // 4) - end_slopes = y_der[-tail_size:] - mean_end_slope = np.mean(end_slopes) - std_end_slope = np.std(end_slopes) - - # Use the 90th percentile of the derivative instead of the absolute maximum. - # This completely filters out the impact of an isolated giant climbing spike. - robust_max_slope = np.percentile(np.abs(y_der), 90) - slope_threshold = max(mean_end_slope + 3.0 * std_end_slope, 0.08 * robust_max_slope) - - # Trace backward from the end point - suffix_start = len(after_pinch) - 1 - while suffix_start > 0 and np.abs(y_der[suffix_start]) <= slope_threshold: - suffix_start -= 1 - - # Safeguard to prevent tracing back into the pinch-off region - if suffix_start <= 2: - suffix_start = len(after_pinch) - 1 - - sat_idx_scan = pinch_off_pos + suffix_start - idx_sat = int(scan_indices[sat_idx_scan]) - - sat_voltage = x1[idx_sat] - sat_current = y1[idx_sat] - sat_threshold = y1[idx_sat] - sat_plateau_start = sat_voltage + pinch_off_current = y1_norm[idx_pinch_off] + + # --- Fit sigmoids (Gompertz function) --- - # --- Fit sigmoids --- - params, popt, pcov = fit_to_function(x1, y1, sigmoid, print_results=False) + params, popt, pcov = fit_to_function(x1, y1_norm, gompertz, p0=[y1_norm.max() * 0.9, 1e5, 10], print_results=False) # For plotting + params2, popt2, pcov2 = fit_to_function(x1, y1, gompertz, p0=[y1_norm.max() * 0.9, 1e5, 10], print_results=False) # For fit calculations # --- Extract key points --- - A, B, V0, dV = popt + A, B, C = popt + A2, B2, C2 = popt2 + + # Calculate parameters from fit + factor = np.log((3 + np.sqrt(5)) / 2) + fit_midpoint_voltage = np.log(B2) / C2 + fit_pinch_off_voltage = fit_midpoint_voltage - (factor / C2) + fit_saturation_voltage = fit_midpoint_voltage + (factor / C2) + sat_voltage = None + sat_current = None + + y_fit = gompertz(x1, *popt) # fit data for plotting + + # Saturation voltage and current determination based on Gate type + if gate_type == 'Accumulation': + sat_voltage = fit_saturation_voltage + sat_current = y1_norm[np.argmax(np.isclose(x1, sat_voltage, atol=1e-3, rtol=1e-3))] + sat_label = 'Saturation Point' + + elif gate_type == 'Plunger': + for y in np.flip(y1): + if np.isclose(y, A2, atol=1e-2, rtol=1e-2): + sat_idx = np.argmax(y1 == y) + sat_voltage = x1[sat_idx] + sat_current = y1_norm[sat_idx] + sat_label = 'Saturation Point' + break + + elif gate_type == 'Barrier': + sat_voltage = fit_midpoint_voltage + sat_current = y1_norm[np.argmax(np.isclose(x1, sat_voltage, atol=1e-3, rtol=1e-3))] + sat_label = 'Midpoint' - y_fit = sigmoid(x1, *popt) + else: + raise TypeError("The gate_type given isn't one of the following: 'Accumulation', 'Plunger', 'Barrier'") # --- Plot data --- if plot_results: fig, ax = plt.subplots(figsize=(8,6)) - ax.plot(x1, y1, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') - ax.scatter(pinch_off_voltage, pinch_off_current, color='red', s=100, zorder=5, label='Pinch-off Point') - ax.scatter(sat_voltage, sat_current, color='green', s=100, zorder=5, label='Saturation Point') - ax.plot(x1, y_fit, '--', color='red', linewidth=2, label='Fitted Sigmoid') + ax.plot(x1, y1_norm, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') - if debug == True: + filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) + fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') - if sat_plateau_start is not None and sat_threshold is not None: - ax.axhline(sat_threshold, color='tab:green', linestyle='--', linewidth=1.25, alpha=0.85, label='Saturation Threshold') - ax.axvspan(sat_plateau_start, x1[scan_indices[-1]], color='tab:green', alpha=0.12) - ax.scatter(sat_plateau_start, sat_threshold, color='tab:green', marker='x', s=80, zorder=6, label='Saturation Start') + ax.scatter(pinch_off_voltage, pinch_off_current, color='red', s=100, zorder=5, label='Pinch-off Point') + ax.scatter(sat_voltage, sat_current, color='green', s=100, zorder=5, label=sat_label) + ax.plot(x1, y_fit, '--', color='red', linewidth=2, label='Fitted Sigmoid') ax.legend(fontsize=20, frameon=False, loc='upper left') - # --- Double-sided arrows showing full range (swapped positions) --- + # --- Double-sided arrows showing full range --- # Define arrow y-positions (swap positions) - y_arrow1 = ax.get_ylim()[1] + 0.05 # Device 1 arrow ABOVE - # y_arrow2 = ax.get_ylim()[0] - 0.01 # Device 2 arrow BELOW + y_arrow1 = ax.get_ylim()[1] + 0.05 # Device 1 arrow # Device 1 arrow (now above) @@ -385,14 +391,13 @@ def extract_pinch_off_curve_ranges(x_data: np.array, ax.text((sat_voltage + pinch_off_voltage)/2, y_arrow1 - 0.05*(ax.get_ylim()[1]-ax.get_ylim()[0]), s='', color='C0', ha='center', va='top', fontsize=20) - # --- Characteristic vertical lines extending exactly to the data points --- + # --- Characteristic vertical lines extending to the data points --- - y_pinch1 = y1[np.where(x1 == pinch_off_voltage)][0] - y_sat1 = y1[np.where(x1 == sat_voltage)][0] + y_pinch1 = y1_norm[np.argmax(np.isclose(x1, pinch_off_voltage, atol=1e-3))] for color, po, sat, label, y_arrow, direction, y_pinch, y_sat in [ # Device 1 → arrow above, extend down to data - ('C0', pinch_off_voltage, sat_voltage, 'Device 1', y_arrow1, 'down', y_pinch1, y_sat1) + ('C0', pinch_off_voltage, sat_voltage, 'Device 1', y_arrow1, 'down', y_pinch1, sat_current) ]: if direction == 'up': # Extend upward from arrow to the y-values of the fitted curve @@ -405,7 +410,15 @@ def extract_pinch_off_curve_ranges(x_data: np.array, # --- Labels and formatting --- - ax.set_xlabel(r'V$_{gate}$ (V)', fontsize=35) + if gate_type == 'Plunger': + ax.set_xlabel(r'V$_{Plunger}$ (V)', fontsize=35) + + elif gate_type == 'Accumulation': + ax.set_xlabel(r'V$_{Accumulation}$ (V)', fontsize=35) + + elif gate_type == 'Barrier': + ax.set_xlabel(r'V$_{Barrier}$ (V)', fontsize=35) + ax.set_ylabel('I (nA)', fontsize=35) ax.minorticks_on() @@ -417,10 +430,10 @@ def extract_pinch_off_curve_ranges(x_data: np.array, ax.set_xticks(xticks_span) ax.set_xticklabels([f'{xticks_span[0]:.2f}', '', f'{xticks_span[2]:.2f}', '', f'{xticks_span[-1]:.2f}'], fontsize=25) - yticks_span = np.linspace(y1.min(), y1.max(), 5) + yticks_span = np.linspace(y1_norm.min(), y1_norm.max(), 5) ax.set_yticks(yticks_span) - ax.set_yticklabels([f'{yticks_span[0]:.2f}', '', '', '', f'{yticks_span[-1]:.3f}'], fontsize=25) + ax.set_yticklabels([f'{y1.min():.2f}', '', '', '', f'{y1.max():.3f}'], fontsize=25) # Extend y-limits slightly to make space for arrows @@ -429,20 +442,14 @@ def extract_pinch_off_curve_ranges(x_data: np.array, plt.tight_layout() - filepath = os.path.join(filepath, filename) - fig.savefig(filepath, dpi = 'figure', bbox_inches='tight') + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) + fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') plt.close(fig) - # --- Print summary --- - - """print(f" Saturation Voltage: {sat_voltage:.3f} V") - print(f" Midpoint Voltage: {V0:.3f} V") - print(f" Pinch-off Voltage: {pinch_off_voltage:.3f} V\n") """ - voltage_window = (pinch_off_voltage, sat_voltage) - return voltage_window, fig + return voltage_window def extract_max_conductance_points(x_data: np.array, y_data: np.array, @@ -472,14 +479,7 @@ def extract_max_conductance_points(x_data: np.array, # --- Find two largest and two smallest conductance points (positive + negative extremes) --- peak_idx_pos, _ = signal.find_peaks(dIdV, height = peak_height[0], prominence = peak_prominence[0], width=peak_width[0]) - peak_idx_neg, test_props = signal.find_peaks(-dIdV, height = peak_height[1], prominence = peak_prominence[1], width=peak_width[1]) - - # for idx, p in enumerate(peak_idx_neg): - # # If the coordinate is near your dip (around V_P = 1.35 V) - # if 1.30 < x1[p] < 1.39: - # print(f"Negative Dip found at V_P = {x1[p]:.3f} V") - # print(f" -> Measured Prominence: {test_props['prominences'][idx]:.2f}") - # print(f" -> Measured Width: {test_props['widths'][idx]:.2f}") + peak_idx_neg, _ = signal.find_peaks(-dIdV, height = peak_height[1], prominence = peak_prominence[1], width=peak_width[1]) peak_idx = np.sort(np.concatenate([peak_idx_pos, peak_idx_neg])) @@ -732,28 +732,6 @@ def extract_working_point(lb_data: np.array, line_gap=hough_gap ) - # roi = None - # roi_offset = (0, 0) - # if device_type == 'electron' and x_idx_mid > 5 and y_idx_mid > 5: - # roi = ridge_masked[y_idx_mid:, :x_idx_mid] - # roi_offset = (0, y_idx_mid) - # elif device_type == 'hole' and x_idx_mid < nx - 5 and y_idx_mid < ny - 5: - # roi = ridge_masked[:y_idx_mid, x_idx_mid:] - # roi_offset = (x_idx_mid, 0) - - # if roi is not None and roi.size > 0: - # extra_lines = transform.probabilistic_hough_line( - # roi, - # threshold=max(5, hough_threshold - 3), - # line_length=max(8, int(minLineLength * 0.12)), - # line_gap=max(1, int(maxLineGap * 0.05)) - # ) - # for p0, p1 in extra_lines: - # lines.append(( - # (p0[0] + roi_offset[0], p0[1] + roi_offset[1]), - # (p1[0] + roi_offset[0], p1[1] + roi_offset[1]) - # )) - # if not lines: # return [] @@ -796,21 +774,6 @@ def extract_working_point(lb_data: np.array, line_candidates.sort(key=lambda item: -item[0]) filtered_lines = [entry[1] for entry in line_candidates] - # if not filtered_lines and roi is not None and roi.size > 0: - # for alt_img in [band_passed, G_uint, ridge_norm]: - # alt_roi = alt_img[:y_idx_mid, :x_idx_mid] if device_type == 'electron' else alt_img[y_idx_mid:, x_idx_mid:] - # extra_lines = transform.probabilistic_hough_line( - # alt_roi, - # threshold=max(4, hough_threshold - 4), - # line_length=max(8, int(minLineLength * 0.12)), - # line_gap=max(1, int(maxLineGap * 0.05)) - # ) - # for p0, p1 in extra_lines: - # lines.append(( - # (p0[0] + roi_offset[0], p0[1] + roi_offset[1]), - # (p1[0] + roi_offset[0], p1[1] + roi_offset[1]) - # )) - line_candidates = [] for p0, p1 in lines: dx, dy = p1[0] - p0[0], p1[1] - p0[1] @@ -1092,6 +1055,9 @@ def extract_working_point(lb_data: np.array, cmap='coolwarm' ) + filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) + fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') + # Set axis limits ax.set_xlim(lb_data.min(), lb_data.max()) ax.set_ylim(rb_data.min(), rb_data.max()) @@ -1329,23 +1295,8 @@ def round_to_step(x, step): return step * np.round(x / step) ax.set_box_aspect(0.775) - # If any shifted points lie outside current axis limits, expand limits slightly - # if len(shifted_points) > 0: - # sx_vals = [p[0] for p in shifted_points] - # sy_vals = [p[1] for p in shifted_points] - # xmin, xmax = ax.get_xlim() - # ymin, ymax = ax.get_ylim() - # pad_x = 0.02 * (xmax - xmin) if (xmax - xmin) != 0 else 0.01 - # pad_y = 0.02 * (ymax - ymin) if (ymax - ymin) != 0 else 0.01 - # new_xmin = min(xmin, min(sx_vals) - pad_x) - # new_xmax = max(xmax, max(sx_vals) + pad_x) - # new_ymin = min(ymin, min(sy_vals) - pad_y) - # new_ymax = max(ymax, max(sy_vals) + pad_y) - # ax.set_xlim(new_xmin, new_xmax) - # ax.set_ylim(new_ymin, new_ymax) - - filepath = os.path.join(filepath, filename) - fig.savefig(filepath, dpi = 'figure', bbox_inches='tight') + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) + fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') logger.info("Figure saved!") @@ -1936,9 +1887,9 @@ def round_to_step(x, step): return step * np.round(x / step) logger.info("Returning...") if DotTuning == 'Triple Dot': - return best_shifted_point, shifted_points, perp_traces_for_plot, fig + return best_shifted_point, shifted_points, perp_traces_for_plot elif DotTuning == 'SET': - return best_shifted_point, perp_bias_points, perp_traces_for_plot, fig + return best_shifted_point, perp_bias_points, perp_traces_for_plot def extract_tunnel_barrier_latching(dp_data: np.array, tb_data: np.array, diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index 6bbe721..5ec6ce2 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -20,7 +20,8 @@ "import numpy as np\n", "import matplotlib.pyplot as plt\n", "\n", - "file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S SC\\\\\"" + "file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S SC\\\\\"\n", + "# file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S Autotuning\\\\\"" ] }, { @@ -73,20 +74,36 @@ " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " TDdata = df[\"Triple Dot Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + " # ext_val = TDdata[-1]\n", + " # dx = abs(Xdata[0] - Xdata[1])\n", + " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", + " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", + " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", + " # TDdata_ext = -1 * np.concatenate([TDdata, Y_ext_arr])\n", "\n", " print(\"Gate pinch-off:\")\n", - " voltage_window = da.pinch_off_curve_ranges(Xdata, TDdata)\n", + " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 1e-3)\n", + " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", + " display(fig)\n", "\n", - "files = [\"B20PO.csv\", \"B21PO.csv\", \"P20PO.csv\"]\n", + "files = [\"B20PO.csv\", \"B21PO.csv\", \"B20S_2.csv\", \"B21S_2.csv\", \"P20PO.csv\"]\n", "for file in files:\n", " file_path_po = file_path + file\n", " df = pd.read_csv(file_path_po)\n", " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " SETdata = df[\"SET Voltage (V)\"].to_numpy()*1e-8*1e9 #nA\n", + " # ext_val = SETdata[-1]\n", + " # dx = abs(Xdata[0] - Xdata[1])\n", + " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", + " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", + " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", + " # SETdata_ext = -1 * np.concatenate([SETdata, Y_ext_arr])\n", "\n", " print(\"Gate pinch-off:\")\n", - " voltage_window = da.pinch_off_curve_ranges(Xdata, SETdata)" + " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 1e-3)\n", + " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", + " display(fig)" ] }, { @@ -112,10 +129,10 @@ " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " TDdata = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", - " nf = np.average(TDdata[-20:])\n", + " nf = np.average(TDdata[-15:])\n", "\n", " print(\"Barrier gate Resweep:\")\n", - " voltage_window, pinch_off_fig = da.pinch_off_curve_ranges(Xdata, TDdata, nf)\n", + " voltage_window, pinch_off_fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, nf)\n", " barrier_pinch_offs.append(voltage_window[0])\n", " display(pinch_off_fig)\n", " \n", @@ -137,27 +154,119 @@ "metadata": {}, "outputs": [], "source": [ - "files = [\"AC0PO.csv\", \"AC1PO.csv\", \"B0PO_2.csv\", \"B1PO_2.csv\", \"B2PO_2.csv\", \"B3PO_2.csv\", \"P0PO_2.csv\", \"P1PO_2.csv\", \"P2PO_2.csv\"]\n", - "for file in files:\n", + "files = [\"AC0PO.csv\", \"AC1PO.csv\", \"B0PO_2.csv\", \"B1PO_2.csv\", \"B2PO_2.csv\", \"B3PO_2.csv\", \"P0PO_2.csv\", \"P1PO_2.csv\", \"P2PO_2.csv\"] # \"AC0PO.csv\", \"AC1PO.csv\", \"B0PO_2.csv\", \"B1PO_2.csv\", \"B2PO_2.csv\", \"B3PO_2.csv\", \"P0PO_2.csv\", \"P1PO_2.csv\", \"P2PO_2.csv\"\n", + "gate_types = ['Accumulation', 'Accumulation', 'Barrier', 'Barrier', 'Barrier', 'Barrier', 'Plunger', 'Plunger', 'Plunger']\n", + "\n", + "for i, file in enumerate(files):\n", " file_path_po = file_path + file\n", " df = pd.read_csv(file_path_po)\n", " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " TDdata = df[\"Triple Dot Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", + " # ext_val = TDdata[-1]\n", + " # dx = abs(Xdata[0] - Xdata[1])\n", + " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", + " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", + " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", + " # TDdata_ext = np.concatenate([TDdata, Y_ext_arr])\n", "\n", - " print(\"Gate pinch-off:\")\n", - " voltage_window = da.pinch_off_curve_ranges(Xdata, TDdata)\n", "\n", - "files = [\"AC2PO.csv\", \"AC3PO.csv\", \"B20PO_2.csv\", \"B21PO_2.csv\", \"P20PO_2.csv\"]\n", - "for file in files:\n", + " print(f\"{gate_types[i]} pinch-off:\")\n", + " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 0.3, gate_types[i])\n", + " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", + " display(fig)\n", + "\n", + "\n", + "files = [\"AC2PO.csv\", \"AC3PO.csv\", \"B20PO_2.csv\", \"B21PO_2.csv\", \"P20PO_2.csv\"] # \"AC2PO.csv\", \"AC3PO.csv\", \"B20PO_2.csv\", \"B21PO_2.csv\", \"P20PO_2.csv\"\n", + "gate_types = ['Accumulation', 'Accumulation', 'Barrier', 'Barrier', 'Plunger']\n", + "\n", + "for i, file in enumerate(files):\n", " file_path_po = file_path + file\n", " df = pd.read_csv(file_path_po)\n", " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " SETdata = df[\"SET Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", + " # ext_val = SETdata[-1]\n", + " # dx = abs(Xdata[0] - Xdata[1])\n", + " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", + " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", + " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", + " # SETdata_ext = np.concatenate([SETdata, Y_ext_arr])\n", "\n", - " print(\"Gate pinch-off:\")\n", - " voltage_window = da.pinch_off_curve_ranges(Xdata, SETdata)" + " print(f\"{gate_types[i]} pinch-off:\")\n", + " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 0.0, gate_types[i])\n", + " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", + " display(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "67da65cc", + "metadata": {}, + "source": [ + "### Pinch-Off for Autotuning Collected Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "815eedc2", + "metadata": {}, + "outputs": [], + "source": [ + "files = [\n", + " \"Middle Dot Plunger_Pinch_Off_2026-06-14_02-11-11.csv\",\n", + " \"Left Dot Plunger_Pinch_Off_2026-06-14_02-06-49.csv\",\n", + " \"Left Dot Accumulation Gate_Pinch_Off_2026-06-15_10-17-55.csv\",\n", + " \"Right Dot Plunger_Pinch_Off_2026-06-14_02-15-32.csv\",\n", + " \"Right Dot Accumulation Gate_Pinch_Off_2026-06-14_22-27-24.csv\"\n", + " ]\n", + "gate_types = ['Plunger', 'Plunger', 'Accumulation', 'Plunger', 'Accumulation']\n", + "\n", + "for i, file in enumerate(files):\n", + " file_path_po = file_path + file\n", + " df = pd.read_csv(file_path_po)\n", + " name = df.columns[0]\n", + " Xdata = df[name].to_numpy()\n", + " name = df.columns[-2]\n", + " TDdata = df[name].to_numpy()*1e-8*1e9 #nA\n", + " # ext_val = TDdata[-1]\n", + " # dx = abs(Xdata[0] - Xdata[1])\n", + " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", + " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", + " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", + " # TDdata_ext = np.concatenate([TDdata, Y_ext_arr])\n", + "\n", + " print(f\"TD {gate_types[i]} Gate pinch-off:\")\n", + " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 0.3, gate_types[i])\n", + " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", + " display(fig)\n", + "\n", + "files = [\n", + " \"Left Sensor Accumulation Gate and Flanking Gates_Pinch_Off_2026-06-14_02-02-27.csv\",\n", + " \"Sensor Plunger_Pinch_Off_2026-06-14_22-50-20.csv\",\n", + " \"Right Sensor Accumulation Gate_Pinch_Off_2026-06-14_02-00-17.csv\"\n", + " ]\n", + "gate_types = ['Accumulation', 'Plunger', 'Accumulation']\n", + "\n", + "for i, file in enumerate(files):\n", + " file_path_po = file_path + file\n", + " df = pd.read_csv(file_path_po)\n", + " name = df.columns[0]\n", + " Xdata = df[name].to_numpy()\n", + " name = df.columns[-1]\n", + " SETdata = df[name].to_numpy()*1e-8*1e9 #nA\n", + " # ext_val = SETdata[-1]\n", + " # dx = abs(Xdata[0] - Xdata[1])\n", + " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", + " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", + " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", + " # SETdata_ext = np.concatenate([SETdata, Y_ext_arr])\n", + "\n", + " print(f\"SET {gate_types[i]} Gate pinch-off:\")\n", + " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 0.3, gate_types[i])\n", + " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", + " display(fig)" ] }, { From bce1d3d637e0f46f84fb3a2b438dcea743f288f7 Mon Sep 17 00:00:00 2001 From: VanOschB <118694763+VanOschB@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:56:59 -0400 Subject: [PATCH 32/36] Global Charge Tuning Functions This commit adds the majority of the Global Charge State tuning funcitons, including a method for calibrating countersweeping and confirming charge transitions, and tuning the tunnel barrier. --- src/autotuning_protocol.py | 549 ++++++++++++++++++++++++++++++++++- src/data_analysis_test.ipynb | 4 +- 2 files changed, 535 insertions(+), 18 deletions(-) diff --git a/src/autotuning_protocol.py b/src/autotuning_protocol.py index 7ed7a36..4fa53a2 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -204,7 +204,7 @@ def __init__(self, device_config, instrument_handler, experiment_handler): i += 0.1 new_sat_voltages.append(i) - working_point = self.barrier_barrier_sweep(lower_voltages = pinch_off_voltages, + working_point, dot_barrier_set_points = self.barrier_barrier_sweep(lower_voltages = pinch_off_voltages, upper_voltages = new_sat_voltages, num_points = 200) @@ -222,6 +222,14 @@ def __init__(self, device_config, instrument_handler, experiment_handler): lower_plunger_voltages = [0.7], upper_plunger_voltages = [1.5], num_points = 100) """ + + self.dot_plunger_lower_voltages = [] + + for i in dot_barrier_set_points: + + voltage = sum(i) / len(i) + + self.dot_plunger_lower_voltages.append(voltage) def ground_device(self, instr_handler, exp_handler): @@ -1400,7 +1408,7 @@ def barrier_barrier_sweep(self, lower_voltages, upper_voltages, num_points): logger.info(f"Best: {best_point}") logger.info(f"{working_points}") - return best_point + return best_point, best_points def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_voltages, num_points): @@ -1607,6 +1615,8 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ logger.info(f"{conductance_points}") + return conductance_points + def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_voltages, upper_plunger_voltages, num_points): # First, we get our S/D biases to their lower thresholds @@ -1817,17 +1827,219 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v logger.info("Coulomb Diamond Sweep Complete! Finding Diamonds...") -class GlobalChargeTuning(Protocol): +class GlobalChargeTuning(Protocol, Bootstrapping): - def __init__(self, device_config): - super().__init__(device_config = device_config) + def __init__(self, device_config, instrument_handler, experiment_handler): + + super().__init__(device_config = device_config, instrument_handler = instrument_handler, experiment_handler = experiment_handler) + + confirmation = self.confirm_charge_transitions(lower_plunger_voltages = self.dot_plunger_lower_voltages, upper_plunger_voltages = [1.5, 1.5, 1.5], num_points = 200) + + logger.info(f"{confirmation}") + + def calibrate_countersweeping(self, lower_dot_plunger_voltages, upper_dot_plunger_voltages, lower_sensor_plunger_voltages, upper_sensor_plunger_voltages, num_points): + + # First, we get the current plunger gate voltages + + dot_plunger_dacs_and_vals = {} + + sensor_plunger_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + dot_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + elif self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + sensor_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + logger.info(f"{dot_plunger_dacs_and_vals}") + + logger.info(f"{sensor_plunger_dacs_and_vals}") + + # Now, we set our dot and sensor plungers to their initial values + + dot_plunger_targets = [] + + sensor_plunger_targets = [] + + endpoint_iter_dot = iter(lower_dot_plunger_voltages) + + endpoint_iter_sensor = iter(lower_sensor_plunger_voltages) + + for gate, dac_and_val in dot_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + dot_plunger_val = next(endpoint_iter_dot) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = dot_plunger_val + ) + + dot_plunger_targets.append(sparam) + + for gate, dac_and_val in sensor_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + dot_plunger_val = next(endpoint_iter_sensor) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = dot_plunger_val + ) + + sensor_plunger_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = dot_plunger_targets + sensor_plunger_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Initial Plunger Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Plunger Voltages Set!") - def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_voltages, num_points): + # Now, we construct 2D scans, in which the Sensor plunger is swept and the dot plungers are stepped - # First, we create our plunger sweeps + dot_plunger_targets = [] + + sensor_plunger_targets = [] + + dot_plunger_idx = 0 + + sensor_plunger_idx = 0 + + dot_plunger_names = [] + + sensor_plunger_names = [] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + p = self.device_gates[i]['channel'] + + gate_name = self.device_gates[i]['label'] + + dot_plunger_names.append(gate_name) + + sparam = SweepParam( + parameter = p, + start = lower_dot_plunger_voltages[dot_plunger_idx], + end = upper_dot_plunger_voltages[dot_plunger_idx] + ) + + dot_plunger_idx += 1 + + elif self.device_gates[i]['type'] == 'Sensor Plunger': + + p = self.device_gates[i]['channel'] + + gate_name = self.device_gates[i]['label'] + + sensor_plunger_names.append(gate_name) + + sparam = SweepParam( + parameter = p, + start = lower_sensor_plunger_voltages[sensor_plunger_idx], + end = upper_sensor_plunger_voltages[sensor_plunger_idx] + ) + + sensor_plunger_idx += 1 + + crosstalk_vals = [] + + for i, sensor in enumerate(sensor_plunger_targets): + + sensor_layer = SweepLayer(targets = [sensor], + num_points = num_points, + measurement_time = 0.1 + ) + + sensor_name = sensor_plunger_names[i] + + for j, dot in enumerate(dot_plunger_targets): + + dot_layer = SweepLayer(targets = [dot], + num_points = num_points, + measurement_time = 0.1 + ) + + dot_name = dot_plunger_names[j] + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([dot_layer, sensor_layer], measure) + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{sensor_name}_{dot_name}_Scan_{time_str}.csv" + + logger.info(f"Determining coupling between {sensor_name} and {dot_name}...") + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename + ) + + logger.info("Scan Complete! Finding cross-talk coefficient...") + + + + return crosstalk_vals + + def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_voltages, sensing_points, num_points): + + # First, we get the currnet plunger gate voltages dot_plunger_dacs_and_vals = {} + sensor_plunger_dacs_and_vals = {} + for i in self.gates_to_dacs: if self.device_gates[i]['type'] == 'Dot Plunger': @@ -1842,11 +2054,90 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta wait=True ) + elif self.device_gates[i]['type'] == 'Sensor Plunger': + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + sensor_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + logger.info(f"{dot_plunger_dacs_and_vals}") + logger.info(f"{sensor_plunger_dacs_and_vals}") + + # Now, we set our plungers to their initial values + + dot_plunger_targets = [] + + sensor_plunger_targets = [] + + endpoint_iter_dot = iter(lower_plunger_voltages) + + endpoint_iter_sensor = iter(sensing_points) + + for gate, dac_and_val in dot_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + dot_plunger_val = next(endpoint_iter_dot) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = dot_plunger_val + ) + + dot_plunger_targets.append(sparam) + + for gate, dac_and_val in sensor_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + sensor_plunger_val = next(endpoint_iter_sensor) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = sensor_plunger_val + ) + + sensor_plunger_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = dot_plunger_targets + sensor_plunger_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Initial Plunger Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Plunger Voltages Set!") + # Now, we sweep our plungers and read the SET current to determine if we can sense charge transitions - sensor_plunger_idx = 0 + confirmations = [] + + dot_plunger_idx = 0 for i in self.gates_to_dacs: @@ -1854,12 +2145,16 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta p = self.device_gates[i]['channel'] + gate_name = self.device_gates[i]['label'] + sparam = SweepParam( parameter = p, - start = lower_plunger_voltages[sensor_plunger_idx], - end = upper_plunger_voltages[sensor_plunger_idx] + start = lower_plunger_voltages[dot_plunger_idx], + end = upper_plunger_voltages[dot_plunger_idx] ) + dot_plunger_idx += 1 + sweep_layer = SweepLayer( targets = [sparam], num_points = num_points, @@ -1876,22 +2171,244 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta sweep = Sweep([sweep_layer], measure) - logger.info("Setting Sensor Plungers to Initial Points...") - + logger.info(f"Confirming Charge Transition detection for {gate_name}...") + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name}_Sweep_{time_str}.csv" future = self.experiment_handler.do_sweep(sweep = sweep, instrument_handler = self.instrument_handler, filename = filename ) - logger.info("Sensor Plungers Set!") + logger.info(f"{gate_name} Sweep Complete! Confirming Transition Detection...") - return + + confirmations.append() + + + return confirmations - def tune_lead_dot_tunneling(self): + def tune_lead_dot_tunneling(self, lower_barrier_voltages, upper_barrier_voltages, lower_plunger_voltages, upper_plunger_voltages, num_points): - # First, we need to + # First, get the lead-barrier voltages, and the dot plunger voltages + + outer_plunger_dacs_and_vals = {} + + lead_barrier_dacs_and_vals = {} + + plunger_idx = 0 + + barrier_idx = 0 + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + if plunger_idx == 0 or plunger_idx == 2: + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + outer_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + plunger_idx += 1 + + elif self.device_gates[i]['type'] == 'Dot Barrier': + + if barrier_idx == 0 or barrier_idx == 3: + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + lead_barrier_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + barrier_idx +=1 + + logger.info(f"{outer_plunger_dacs_and_vals}") + + logger.info(f"{lead_barrier_dacs_and_vals}") + + # Now, we set our plungers and barriers to their starting values + + plunger_targets = [] + + barrier_targets = [] + + endpoint_iter_plunger = iter(lower_plunger_voltages) + + endpoint_iter_barrier = iter(lower_barrier_voltages) + + for gate, dac_and_val in outer_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + plunger_val = next(endpoint_iter_plunger) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = plunger_val + ) + + plunger_targets.append(sparam) + + for gate, dac_and_val in lead_barrier_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + barrier_val = next(endpoint_iter_barrier) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = barrier_val + ) + + barrier_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = plunger_targets + barrier_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Initial Lead Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Lead Voltages Set!") + + # Now, we create our sweeps + + plunger_targets = [] + + barrier_targets = [] + + plunger_idx = 0 + + barrier_idx = 0 + + plunger_names = [] + + barrier_names = [] + + startpoint_iter_plunger = iter(lower_plunger_voltages) + + endpoint_iter_plunger = iter(upper_plunger_voltages) + + startpoint_iter_barrier = iter(lower_barrier_voltages) + + endpoint_iter_barrier = iter(upper_barrier_voltages) + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + if plunger_idx == 0 or plunger_idx == 2: + + p = self.device_gates[i]['channel'] + + gate_name = self.device_gates[i]['label'] + + plunger_names.append(gate_name) + + start_val = next(startpoint_iter_plunger) + + end_val = next(endpoint_iter_plunger) + + sparam = SweepParam( + parameter = p, + start = start_val, + end = end_val + ) + + plunger_targets.append(sparam) + + plunger_idx += 1 + + elif self.device_gates[i]['type'] == 'Dot Barrier': + + if barrier_idx == 0 or barrier_idx == 3: + + p = self.device_gates[i]['channel'] + + gate_name = self.device_gates[i]['label'] + + barrier_names.append(gate_name) + + start_val = next(startpoint_iter_barrier) + + end_val = next(endpoint_iter_barrier) + + sparam = SweepParam( + parameter = p, + start = start_val, + end = end_val + ) + + barrier_targets.append(sparam) + + plunger_idx += 1 + + for i, item in enumerate(plunger_targets): + + plunger_layer = SweepLayer( + targets = [item], + num_points = num_points, + measurement_time = 0.2 + ) + + barrier_layer = SweepLayer( + targets = [barrier_targets[i]], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([barrier_layer, plunger_layer], measure) + + logger.info(f"{plunger_names[i]} vs. {barrier_names[i]} scan starting...") + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{plunger_names[i]}_{barrier_names[i]}_Scan_{time_str}.csv" + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename + ) + + logger.info(f"{plunger_names[i]} vs. {barrier_names[i]} scan complete! Finding appropriate barrier voltage...") return diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index 5ec6ce2..80d573e 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -350,7 +350,7 @@ ], "metadata": { "kernelspec": { - "display_name": ".venv", + "display_name": "Tuner", "language": "python", "name": "python3" }, @@ -364,7 +364,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.3" + "version": "3.10.20" } }, "nbformat": 4, From 6f94657c3f4a4d884132fc3a39544f764a4ce619 Mon Sep 17 00:00:00 2001 From: VanOschB <118694763+VanOschB@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:12:40 -0400 Subject: [PATCH 33/36] Global Charge Tuning Init Modified the GCT init to only run the protocol init, instead of the bootstrapping init as well. --- src/autotuning_protocol.py | 64 ++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/autotuning_protocol.py b/src/autotuning_protocol.py index 4fa53a2..8d504e5 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -2374,46 +2374,50 @@ def tune_lead_dot_tunneling(self, lower_barrier_voltages, upper_barrier_voltages plunger_idx += 1 - for i, item in enumerate(plunger_targets): + barrier_setpoints = [] - plunger_layer = SweepLayer( - targets = [item], - num_points = num_points, - measurement_time = 0.2 - ) - - barrier_layer = SweepLayer( - targets = [barrier_targets[i]], - num_points = num_points, - measurement_time = 0.2 - ) + for i, item in enumerate(plunger_targets): - measure = lambda ih, sp: ( - ih.read_buffer([ - 'agilent_left.volt', - 'agilent_right.volt' - ]), - ['agilent_left.volt', 'agilent_right.volt'] - ) + plunger_layer = SweepLayer( + targets = [item], + num_points = num_points, + measurement_time = 0.2 + ) - sweep = Sweep([barrier_layer, plunger_layer], measure) + barrier_layer = SweepLayer( + targets = [barrier_targets[i]], + num_points = num_points, + measurement_time = 0.2 + ) - logger.info(f"{plunger_names[i]} vs. {barrier_names[i]} scan starting...") + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) - time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"{plunger_names[i]}_{barrier_names[i]}_Scan_{time_str}.csv" + sweep = Sweep([barrier_layer, plunger_layer], measure) - future = self.experiment_handler.do_sweep(sweep = sweep, - instrument_handler = self.instrument_handler, - filename = filename - ) - - logger.info(f"{plunger_names[i]} vs. {barrier_names[i]} scan complete! Finding appropriate barrier voltage...") + logger.info(f"{plunger_names[i]} vs. {barrier_names[i]} scan starting...") - return + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{plunger_names[i]}_{barrier_names[i]}_Scan_{time_str}.csv" + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename + ) + + logger.info(f"{plunger_names[i]} vs. {barrier_names[i]} scan complete! Finding appropriate barrier voltage...") + return barrier_setpoints + def plunger_plunger_sweep(): + + # First, we get the current + pass class VirtualGating(Protocol): From 34407762528f5bd3bcbe19433537f93836a5f5e0 Mon Sep 17 00:00:00 2001 From: Drv5MC <116960417+Drv5MC@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:17:15 -0400 Subject: [PATCH 34/36] Countersweeping and hough transform code Code for determining the cross-talk line through hough transform and the peak conductance and the pair voltage associated with it. --- src/data_analysis.py | 263 +++++++++++++++++++++++++++++++++-- src/data_analysis_test.ipynb | 132 +++++++++--------- 2 files changed, 315 insertions(+), 80 deletions(-) diff --git a/src/data_analysis.py b/src/data_analysis.py index 32079be..7d5aaa3 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -191,7 +191,6 @@ def extract_turn_on_voltage(x_data: np.array, ax.plot(x1, y1, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) - fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') ax.scatter(turnon_voltage, turnon_current, color='red', s=100, zorder=5, label='Turn-On Point') @@ -219,7 +218,6 @@ def extract_turn_on_voltage(x_data: np.array, plt.tight_layout() filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) - fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') plt.close(fig) @@ -233,8 +231,8 @@ def extract_pinch_off_curve_ranges(x_data: np.array, y_data: np.array, noisefloor: float, gate_type: str, - filepath: str, - filename: str, + # filepath: str, + # filename: str, plot_results: bool = True): """Identify pinch-off and saturation voltage ranges for a sweep. @@ -366,8 +364,8 @@ def extract_pinch_off_curve_ranges(x_data: np.array, fig, ax = plt.subplots(figsize=(8,6)) ax.plot(x1, y1_norm, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') - filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) - fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') + # filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) + # fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') ax.scatter(pinch_off_voltage, pinch_off_current, color='red', s=100, zorder=5, label='Pinch-off Point') ax.scatter(sat_voltage, sat_current, color='green', s=100, zorder=5, label=sat_label) @@ -442,10 +440,11 @@ def extract_pinch_off_curve_ranges(x_data: np.array, plt.tight_layout() - filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) - fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') + # filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) + # fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') - plt.close(fig) + # plt.close(fig) + plt.show() voltage_window = (pinch_off_voltage, sat_voltage) @@ -490,13 +489,15 @@ def extract_max_conductance_points(x_data: np.array, max_idx = peak_idx[np.argmax(dIdV[peak_idx])] min_idx = peak_idx[np.argmin(dIdV[peak_idx])] - + + x_max = x1[max_idx] + x_min = x1[min_idx] I_max = y1[max_idx] I_min = y1[min_idx] G_max = dIdV[max_idx] G_min = dIdV[min_idx] - best_sens_pts = [(x1[max_idx], I_max), (x1[min_idx], I_min)] + best_sens_pts = [(x_max, I_max), (x_min, I_min)] # Create two subplots that share the x-axis fig, (ax1, ax2) = plt.subplots(2, 1, sharex=False, figsize=(8, 6)) @@ -576,7 +577,7 @@ def extract_max_conductance_points(x_data: np.array, plt.subplots_adjust(hspace=0.40) plt.show() - return best_sens_pts, G_top + return best_sens_pts, (x_top, G_top) def extract_working_point(lb_data: np.array, rb_data: np.array, @@ -2197,3 +2198,241 @@ def F(U, G, G0): plt.show() return results +def extract_max_conductance_pair(x_data: np.array, + y_data: np.array, + filepath: str, + filename: str, + peak_height: list[float] = [None, None], + peak_prominence: list[float] = [None, None], + peak_width: list[float] = [None, None] + ): + + """Analyze current data to identify the largest conductance peak and it's pair feature on the same peak. + + This function plots the current and its derivative, then highlights + the most extreme conductance peak along with the pair that's on the same peak. + """ + + x1 = np.array(x_data) + y1 = np.array(y_data) + + # Now, we calculate the derivative and replot + + dIdV = np.gradient(y1, x1) + + posdIdV = abs(dIdV) + + if peak_height == [None, None]: + peak_height = [0.25 * np.max(dIdV), 0.25 * np.max(dIdV)] + if peak_prominence == [None, None]: + peak_prominence = [0.3 * np.max(dIdV), 0.3 * np.max(dIdV)] + + peak_idx_pos, _ = signal.find_peaks(dIdV, height = peak_height[0], prominence = peak_prominence[0], width=peak_width[0]) + peak_idx_neg, _ = signal.find_peaks(-dIdV, height = peak_height[1], prominence = peak_prominence[1], width=peak_width[1]) + + peak_idx = np.sort(np.concatenate([peak_idx_pos, peak_idx_neg])) + + x_top = x1[peak_idx] + I_top = y1[peak_idx] + G_top = dIdV[peak_idx] + + max_idx = peak_idx[np.argmax(posdIdV[peak_idx])] + + x_max = x1[max_idx] + I_max = y1[max_idx] + G_max = dIdV[max_idx] + + if G_max > 0: + pair_idx = peak_idx[np.where(max_idx == peak_idx)[0][0] + 1] + else: + pair_idx = peak_idx[np.where(max_idx == peak_idx)[0][0] - 1] + + x_pair = x1[pair_idx] + I_pair = y1[pair_idx] + G_pair = dIdV[pair_idx] + + if G_max > 0: + conductance_pair = (x_max, x_pair) + else: + conductance_pair = (x_pair, x_max) + + # Create two subplots that share the x-axis + fig, (ax1, ax2) = plt.subplots(2, 1, sharex=False, figsize=(8, 6)) + + # --- Top panel: Current --- + ax1.plot(x1, y1, color='#2c5aa0', linewidth=1) + + filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) + fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') + + ax1.scatter(x_max, I_max, facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Max I') + ax1.scatter(x_pair, I_pair, facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Max I') + ax1.set_ylabel('I (nA)', fontsize=35) + ax1.set_ylim(bottom=0) + ax1.set_xlim(min(x1), max(x1)) + ax1.tick_params(labelbottom=True) + + # --- Bottom panel: Conductance --- + ax2.plot(x1, dIdV, color='#2c5aa0', linewidth=1) + ax1.scatter(x_max, G_max, facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Max G') + ax1.scatter(x_pair, G_pair, facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Max G') + ax2.set_xlabel(r'$V_P$ (V)', fontsize=35) + ax2.set_ylabel('G (nS)', fontsize=35) + ax1.set_ylim(bottom=0) + ax2.set_xlim(min(x1), max(x1)) + + # --- Create the connection line --- + con = ConnectionPatch( + xyA=(x_max, I_max), coordsA=ax1.transData, + xyB=(x_max, G_max), coordsB=ax2.transData, + color='#01FF05', linestyle='--', linewidth=0.7 + ) + fig.add_artist(con) + + con = ConnectionPatch( + xyA=(x_pair, I_pair), coordsA=ax1.transData, + xyB=(x_pair, G_pair), coordsB=ax2.transData, + color='#01FF05', linestyle='--', linewidth=0.7 + ) + fig.add_artist(con) + + # --- Create a custom legend entry (hollow circle) --- + legend_marker = mlines.Line2D([], [], color='#01FF05', marker='o', + markerfacecolor='none', markersize=10, + linewidth=0, label='Best Conductance Points') + + # --- Custom tick labels: only min and max shown --- + + # Get existing ticks (so tick marks stay) + for ax in [ax1, ax2]: + + ax.minorticks_on() + ax.tick_params(which='minor', direction='in', length=3, top=True, right=True) + ax.tick_params(direction='in', length=5, width=1.2, labelsize=20, top=True, right=True) + xticks = ax.get_xticks() + yticks = ax.get_yticks() + + ax1.set_xticks([np.round(x1.min(), 3), np.round((x1.min() + x1.max()) / 2, 3), np.round(x1.max(), 3)]) + ax1.set_xticklabels([str(np.round(x1.min(), 3)), str(np.round((x1.min() + x1.max()) / 2, 3)), str(np.round(x1.max(), 3))], fontsize=25) + + ax1.set_yticks([0, np.round(y1.max(), 3)]) + ax1.set_yticklabels(['0', str(np.round(y1.max(), 3))], fontsize=25) + + ax2.set_xticks([np.round(x1.min(), 3), np.round((x1.min() + x1.max()) / 2, 3), np.round(x1.max(), 3)]) + ax2.set_xticklabels([str(np.round(x1.min(), 3)), str(np.round((x1.min() + x1.max()) / 2, 3)), str(np.round(x1.max(), 3))], fontsize=25) + + ax2.set_yticks([np.round(dIdV.min(), 1), 0, np.round(dIdV.max(), 1)]) + ax2.set_yticklabels([str(np.round(dIdV.min(), 1)), '0', str(np.round(dIdV.max(), 1))], fontsize=25) + + ax1.legend(handles=legend_marker, loc='upper left', fontsize=16, frameon=False) + + # --- Adjust layout --- + plt.subplots_adjust(hspace=0.40) + + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) + fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') + + plt.close(fig) + + return conductance_pair + +def hough_transform(x_data: np.array, + y_data: np.array, + current_data: np.array, + filepath: str, + filename: str, + transform_trim: list = [0, -1] + ): + """ + Analyze voltage data to find the slope of the line seen by cross-talk measurements using the hough transform. + Can be used as regualr hough transform as well. + + This function plots the voltage, then highlights + the slope of the cross-talk line seen. + The transform_trim argument allows you to remove points from the hough tranform analysis to make the fit better (mainly for debugging) + """ + + # Ensure arguments are arrays + x1 = np.array(x_data) + y1 = np.array(y_data) + I1 = np.array(current_data) + + unique_x = np.unique(x1) + unique_y = np.unique(y1) + + # Get x voltage spacing + dx = x1[1] - x1[0] + + trace_len = len(unique_x) # Number of columns (x1) + num_traces = len(unique_y) # Number of rows (y1) + + X_matrix, Y_matrix = np.meshgrid(unique_x, unique_y) # Get the X and Y grids to be used for analysis and plotting + Z_matrix = np.full((num_traces, trace_len), np.nan) # Create an empty Z matrix + # Fill in the Z matrix with current data for proper plotting + for x, y, I in zip(x1, y1, I1): + idx_x = np.where(unique_x == x) + idx_y = np.where(unique_y == y) + Z_matrix[idx_y, idx_x] = I + x_vals = [] + y_vals = [] + + # Plot the 2D color map and save the raw data prior to any analysis + + fig, ax = plt.subplots(figsize=(8,6)) + + ax.pcolormesh( + unique_x, + unique_y, + Z_matrix, + shading='auto', + cmap='viridis' + ) + ax.colorbar(label = "I (nA)") + ax.xlabel("Gate X Voltage (V)") + ax.ylabel("Gate Y Voltage (V)") + + filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) + fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') + + # Analysis using hough transform line detection + + for j in range(num_traces): + # Go through each trace along the y-axis + trace_z = Z_matrix[j, :] + z_der = signal.savgol_filter(trace_z, window_length=11, polyorder=3, deriv=1, delta=dx) + der_min = np.min(z_der) + # if avg == True: + der_max = np.max(z_der) + idx1 = np.where(z_der == der_min)[0][0] + idx2 = np.where(z_der == der_max)[0][0] + xpt1 = float(X_matrix[j, idx1]) + xpt2 = float(X_matrix[j, idx2]) + ypt1 = float(Y_matrix[j, idx1]) + ypt2 = float(Y_matrix[j, idx2]) + x_vals.append(np.mean([xpt1, xpt2])) + y_vals.append(np.mean([ypt1, ypt2])) + # else: + # idx = np.where(z_der == der_min)[0][0] + # x_vals.append(float(X_matrix[j, idx])) + # y_vals.append(float(Y_matrix[j, idx])) + + if len(x_vals) != len(y_vals): + raise ValueError("lengths of x and y values don't match") + else: + start = transform_trim[0] + end = transform_trim[1] + x_vals = x_vals[start:end] + y_vals = y_vals[start:end] + slope, intercept = np.polyfit(x_vals, y_vals, 1) + + ax.plot(x_vals, np.polyval([slope, intercept], x_vals), color='r', linestyle='-') + + # Set the limits and show + ax.ylim(min(y_vals), max(y_vals)) + + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) + fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') + + plt.close(fig) + + return slope, intercept diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index 5ec6ce2..661055c 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -20,7 +20,7 @@ "import numpy as np\n", "import matplotlib.pyplot as plt\n", "\n", - "file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S SC\\\\\"\n", + "file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S FC\\\\\"\n", "# file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S Autotuning\\\\\"" ] }, @@ -68,42 +68,34 @@ "outputs": [], "source": [ "files = [\"B0PO.csv\", \"B1PO.csv\", \"B2PO.csv\", \"B3PO.csv\", \"P0PO.csv\", \"P1PO.csv\", \"P2PO.csv\"]\n", - "for file in files:\n", + "gate_types = ['Barrier', 'Barrier', 'Barrier', 'Barrier', 'Plunger', 'Plunger', 'Plunger']\n", + "\n", + "for i, file in enumerate(files):\n", " file_path_po = file_path + file\n", " df = pd.read_csv(file_path_po)\n", " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " TDdata = df[\"Triple Dot Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", - " # ext_val = TDdata[-1]\n", - " # dx = abs(Xdata[0] - Xdata[1])\n", - " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", - " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", - " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", - " # TDdata_ext = -1 * np.concatenate([TDdata, Y_ext_arr])\n", "\n", - " print(\"Gate pinch-off:\")\n", - " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 1e-3)\n", + " print(f\"{gate_types[i]} pinch-off:\")\n", + " voltage_window = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 1e-3, gate_types[i])\n", " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", - " display(fig)\n", + " # display(fig)\n", "\n", "files = [\"B20PO.csv\", \"B21PO.csv\", \"B20S_2.csv\", \"B21S_2.csv\", \"P20PO.csv\"]\n", - "for file in files:\n", + "gate_types = ['Barrier', 'Barrier', 'Barrier', 'Barrier', 'Plunger']\n", + "\n", + "for i, file in enumerate(files):\n", " file_path_po = file_path + file\n", " df = pd.read_csv(file_path_po)\n", " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " SETdata = df[\"SET Voltage (V)\"].to_numpy()*1e-8*1e9 #nA\n", - " # ext_val = SETdata[-1]\n", - " # dx = abs(Xdata[0] - Xdata[1])\n", - " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", - " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", - " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", - " # SETdata_ext = -1 * np.concatenate([SETdata, Y_ext_arr])\n", "\n", - " print(\"Gate pinch-off:\")\n", - " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 1e-3)\n", + " print(f\"{gate_types[i]} pinch-off:\")\n", + " voltage_window = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 1e-3, gate_types[i])\n", " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", - " display(fig)" + " # display(fig)" ] }, { @@ -122,8 +114,10 @@ "outputs": [], "source": [ "files = [\"B20S_2.csv\", \"B21S_2.csv\"]\n", + "gate_types = ['Barrier', 'Barrier']\n", "barrier_pinch_offs = []\n", - "for file in files:\n", + "\n", + "for i, file in enumerate(files):\n", " file_path_bg = file_path + file\n", " df = pd.read_csv(file_path_bg)\n", " name = df.columns[1]\n", @@ -132,7 +126,7 @@ " nf = np.average(TDdata[-15:])\n", "\n", " print(\"Barrier gate Resweep:\")\n", - " voltage_window, pinch_off_fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, nf)\n", + " voltage_window, pinch_off_fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, nf, gate_types[i])\n", " barrier_pinch_offs.append(voltage_window[0])\n", " display(pinch_off_fig)\n", " \n", @@ -154,8 +148,8 @@ "metadata": {}, "outputs": [], "source": [ - "files = [\"AC0PO.csv\", \"AC1PO.csv\", \"B0PO_2.csv\", \"B1PO_2.csv\", \"B2PO_2.csv\", \"B3PO_2.csv\", \"P0PO_2.csv\", \"P1PO_2.csv\", \"P2PO_2.csv\"] # \"AC0PO.csv\", \"AC1PO.csv\", \"B0PO_2.csv\", \"B1PO_2.csv\", \"B2PO_2.csv\", \"B3PO_2.csv\", \"P0PO_2.csv\", \"P1PO_2.csv\", \"P2PO_2.csv\"\n", - "gate_types = ['Accumulation', 'Accumulation', 'Barrier', 'Barrier', 'Barrier', 'Barrier', 'Plunger', 'Plunger', 'Plunger']\n", + "files = [\"B0PO_2.csv\", \"B1PO_2.csv\", \"B2PO_2.csv\", \"B3PO_2.csv\", \"P0PO_2.csv\", \"P1PO_2.csv\", \"P2PO_2.csv\"] # \"AC0PO.csv\", \"AC1PO.csv\", \"B0PO_2.csv\", \"B1PO_2.csv\", \"B2PO_2.csv\", \"B3PO_2.csv\", \"P0PO_2.csv\", \"P1PO_2.csv\", \"P2PO_2.csv\"\n", + "gate_types = ['Barrier', 'Barrier', 'Barrier', 'Barrier', 'Plunger', 'Plunger', 'Plunger']\n", "\n", "for i, file in enumerate(files):\n", " file_path_po = file_path + file\n", @@ -163,22 +157,16 @@ " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " TDdata = df[\"Triple Dot Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", - " # ext_val = TDdata[-1]\n", - " # dx = abs(Xdata[0] - Xdata[1])\n", - " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", - " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", - " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", - " # TDdata_ext = np.concatenate([TDdata, Y_ext_arr])\n", "\n", "\n", " print(f\"{gate_types[i]} pinch-off:\")\n", - " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 0.3, gate_types[i])\n", + " voltage_window = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 1e-3, gate_types[i])\n", " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", - " display(fig)\n", + " # display(fig)\n", "\n", "\n", - "files = [\"AC2PO.csv\", \"AC3PO.csv\", \"B20PO_2.csv\", \"B21PO_2.csv\", \"P20PO_2.csv\"] # \"AC2PO.csv\", \"AC3PO.csv\", \"B20PO_2.csv\", \"B21PO_2.csv\", \"P20PO_2.csv\"\n", - "gate_types = ['Accumulation', 'Accumulation', 'Barrier', 'Barrier', 'Plunger']\n", + "files = [\"AC3PO.csv\", \"B20PO_2.csv\", \"B21PO_2.csv\", \"P20PO_2.csv\"] # \"AC2PO.csv\", \"AC3PO.csv\", \"B20PO_2.csv\", \"B21PO_2.csv\", \"P20PO_2.csv\"\n", + "gate_types = ['Accumulation', 'Barrier', 'Barrier', 'Plunger']\n", "\n", "for i, file in enumerate(files):\n", " file_path_po = file_path + file\n", @@ -186,17 +174,11 @@ " name = df.columns[1]\n", " Xdata = df[name].to_numpy()\n", " SETdata = df[\"SET Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", - " # ext_val = SETdata[-1]\n", - " # dx = abs(Xdata[0] - Xdata[1])\n", - " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", - " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", - " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", - " # SETdata_ext = np.concatenate([SETdata, Y_ext_arr])\n", "\n", " print(f\"{gate_types[i]} pinch-off:\")\n", - " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 0.0, gate_types[i])\n", + " voltage_window = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 1e-3, gate_types[i])\n", " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", - " display(fig)" + " # display(fig)" ] }, { @@ -217,11 +199,11 @@ "files = [\n", " \"Middle Dot Plunger_Pinch_Off_2026-06-14_02-11-11.csv\",\n", " \"Left Dot Plunger_Pinch_Off_2026-06-14_02-06-49.csv\",\n", - " \"Left Dot Accumulation Gate_Pinch_Off_2026-06-15_10-17-55.csv\",\n", + " # \"Left Dot Accumulation Gate_Pinch_Off_2026-06-15_10-17-55.csv\",\n", " \"Right Dot Plunger_Pinch_Off_2026-06-14_02-15-32.csv\",\n", - " \"Right Dot Accumulation Gate_Pinch_Off_2026-06-14_22-27-24.csv\"\n", + " # \"Right Dot Accumulation Gate_Pinch_Off_2026-06-14_22-27-24.csv\"\n", " ]\n", - "gate_types = ['Plunger', 'Plunger', 'Accumulation', 'Plunger', 'Accumulation']\n", + "gate_types = ['Plunger', 'Plunger', 'Plunger']\n", "\n", "for i, file in enumerate(files):\n", " file_path_po = file_path + file\n", @@ -230,24 +212,18 @@ " Xdata = df[name].to_numpy()\n", " name = df.columns[-2]\n", " TDdata = df[name].to_numpy()*1e-8*1e9 #nA\n", - " # ext_val = TDdata[-1]\n", - " # dx = abs(Xdata[0] - Xdata[1])\n", - " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", - " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", - " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", - " # TDdata_ext = np.concatenate([TDdata, Y_ext_arr])\n", "\n", " print(f\"TD {gate_types[i]} Gate pinch-off:\")\n", - " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 0.3, gate_types[i])\n", + " voltage_window = da.extract_pinch_off_curve_ranges(Xdata, TDdata, 1e-3, gate_types[i])\n", " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", - " display(fig)\n", + " # display(fig)\n", "\n", "files = [\n", " \"Left Sensor Accumulation Gate and Flanking Gates_Pinch_Off_2026-06-14_02-02-27.csv\",\n", " \"Sensor Plunger_Pinch_Off_2026-06-14_22-50-20.csv\",\n", - " \"Right Sensor Accumulation Gate_Pinch_Off_2026-06-14_02-00-17.csv\"\n", + " # \"Right Sensor Accumulation Gate_Pinch_Off_2026-06-14_02-00-17.csv\"\n", " ]\n", - "gate_types = ['Accumulation', 'Plunger', 'Accumulation']\n", + "gate_types = ['Accumulation', 'Plunger']\n", "\n", "for i, file in enumerate(files):\n", " file_path_po = file_path + file\n", @@ -256,17 +232,11 @@ " Xdata = df[name].to_numpy()\n", " name = df.columns[-1]\n", " SETdata = df[name].to_numpy()*1e-8*1e9 #nA\n", - " # ext_val = SETdata[-1]\n", - " # dx = abs(Xdata[0] - Xdata[1])\n", - " # X_ext_arr = np.flip(np.arange(0.0, Xdata[-1], dx))\n", - " # Xdata_ext = np.concatenate([Xdata, X_ext_arr])\n", - " # Y_ext_arr = np.full(len(X_ext_arr), ext_val)\n", - " # SETdata_ext = np.concatenate([SETdata, Y_ext_arr])\n", "\n", " print(f\"SET {gate_types[i]} Gate pinch-off:\")\n", - " voltage_window, fig = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 0.3, gate_types[i])\n", + " voltage_window = da.extract_pinch_off_curve_ranges(Xdata, SETdata, 1e-3, gate_types[i])\n", " print(f\"Pinch-Off: {voltage_window[0]}, Saturation: {voltage_window[1]}\")\n", - " display(fig)" + " # display(fig)" ] }, { @@ -316,9 +286,9 @@ "file_path_co = file_path + \"P20S.csv\"\n", "df = pd.read_csv(file_path_co)\n", "Xdata = df[\"P20 (V)\"].to_numpy()\n", - "SETData = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "SETdata = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", "\n", - "best_pts, _ = da.extract_max_conductance_points(Xdata, SETData)\n", + "best_pts, _ = da.extract_max_conductance_points(Xdata, SETdata)\n", "print(best_pts)" ] }, @@ -341,11 +311,37 @@ "df = pd.read_csv(file_path_dl)\n", "Xdata = df[\"P0 (V)\"].to_numpy()\n", "Ydata = df[\"B0 (V)\"].to_numpy()\n", - "SETData = df[\"SET Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", + "SETdata = df[\"SET Signal (V)\"].to_numpy()*1e-8*1e9 #nA\n", "\n", - "best_sens_pts_list, all_sens_pts_list, set_point = da.extract_tunnel_barrier_latching(Xdata, Ydata, SETData)\n", + "best_sens_pts_list, all_sens_pts_list, set_point = da.extract_tunnel_barrier_latching(Xdata, Ydata, SETdata)\n", "print(set_point)" ] + }, + { + "cell_type": "markdown", + "id": "217a8701", + "metadata": {}, + "source": [ + "### Plunger Gate Countersweeping" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef50e8d6", + "metadata": {}, + "outputs": [], + "source": [ + "file_path_cs = file_path + \"P0P20CS.csv\"\n", + "df = pd.read_csv(file_path_cs)\n", + "Xdata = df[\"P0 (V)\"].to_numpy()\n", + "SETdata = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + "der = np.gradient(SETdata, Xdata)\n", + "height = abs(der).max()*0.81\n", + "\n", + "best_pts, conductance = da.extract_max_conductance_points(Xdata, SETdata, peak_height=[height, height])\n", + "print(conductance)" + ] } ], "metadata": { From 339832c1763d2289a597f55dba23215cd8439761 Mon Sep 17 00:00:00 2001 From: Drv5MC <116960417+Drv5MC@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:21:12 -0400 Subject: [PATCH 35/36] Uncommenting the logger and savefig code lines --- src/data_analysis.py | 17 ++++++++--------- src/data_analysis_test.ipynb | 4 ++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/data_analysis.py b/src/data_analysis.py index 7d5aaa3..769aa81 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -50,7 +50,7 @@ from nicegui import ui from tunerlog import TunerLog -# logger = TunerLog('Data Analysis') +logger = TunerLog('Data Analysis') def logarithmic(x, a, b, x0, y0): @@ -231,8 +231,8 @@ def extract_pinch_off_curve_ranges(x_data: np.array, y_data: np.array, noisefloor: float, gate_type: str, - # filepath: str, - # filename: str, + filepath: str, + filename: str, plot_results: bool = True): """Identify pinch-off and saturation voltage ranges for a sweep. @@ -364,8 +364,8 @@ def extract_pinch_off_curve_ranges(x_data: np.array, fig, ax = plt.subplots(figsize=(8,6)) ax.plot(x1, y1_norm, '-', color='C0', linewidth=2, label='I ($V_{gate}$)') - # filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) - # fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') + filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) + fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') ax.scatter(pinch_off_voltage, pinch_off_current, color='red', s=100, zorder=5, label='Pinch-off Point') ax.scatter(sat_voltage, sat_current, color='green', s=100, zorder=5, label=sat_label) @@ -440,11 +440,10 @@ def extract_pinch_off_curve_ranges(x_data: np.array, plt.tight_layout() - # filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) - # fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) + fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') - # plt.close(fig) - plt.show() + plt.close(fig) voltage_window = (pinch_off_voltage, sat_voltage) diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb index fa94f54..661055c 100644 --- a/src/data_analysis_test.ipynb +++ b/src/data_analysis_test.ipynb @@ -346,7 +346,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Tuner", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -360,7 +360,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.20" + "version": "3.14.3" } }, "nbformat": 4, From 3bd910ca6e9d5ab392a4f29385a1dd8731f66fcb Mon Sep 17 00:00:00 2001 From: Ben Van Osch Date: Wed, 24 Jun 2026 12:54:14 -0400 Subject: [PATCH 36/36] June 24th (First Autotuning Cooldown) Code Commit A commit including all of the changes made to the autotuning code up to and including June 24th. For the pinch-off curves, the gompertz function is now being used to fit rather than a traditional sigmoid. This function better accounts for asymmetry in the sigmoidal curve data taken. Functions used to calibrate for and detect charge transitions in the charge sensor were implemented, as well as a general plunger-plunger scan funtion. --- src/autotuning_handler.py | 13 +- src/autotuning_protocol.py | 1401 ++++++++++++++++++++++++++++-------- src/data_analysis.py | 60 +- src/gui.py | 20 +- 4 files changed, 1157 insertions(+), 337 deletions(-) diff --git a/src/autotuning_handler.py b/src/autotuning_handler.py index 99797ec..a375dda 100644 --- a/src/autotuning_handler.py +++ b/src/autotuning_handler.py @@ -179,16 +179,21 @@ def sweep_fn(abort_event): ) def run_global_charge_tuning(self, - sweep, + device_config, instrument_handler, - current_setpoints = {}, + experiment_handler, wait: bool = True, - timeout: float = 60): + timeout: float = 6000): def sweep_fn(abort_event): - result = GlobalChargeTuning() + result = GlobalChargeTuning(device_config = device_config, + instrument_handler = instrument_handler, + experiment_handler = experiment_handler + ) return result + logger.info("adding job!") + return self.autotuning_thread.add_job( sweep_fn, args=(), diff --git a/src/autotuning_protocol.py b/src/autotuning_protocol.py index 8d504e5..2884a45 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -29,7 +29,7 @@ from datetime import datetime import threading from experiment_base import SweepParam, SweepLayer, Sweep -from data_analysis import extract_turn_on_voltage, extract_pinch_off_curve_ranges, extract_working_point, extract_max_conductance_points, extract_lever_arms +from data_analysis import extract_turn_on_voltage, extract_pinch_off_curve_ranges, extract_working_point, extract_max_conductance_pair, extract_max_conductance_points, extract_lever_arms, hough_transform import sys @@ -150,7 +150,6 @@ def _load_config_file(self, device_config): self.SET_preamp_bias = self.config['setup']['SET_preamp']['preamp_bias'] self.SET_preamp_sensitivity = self.config['setup']['SET_preamp']['preamp_sensitivity'] - class Bootstrapping(Protocol): def __init__(self, device_config, instrument_handler, experiment_handler): @@ -191,38 +190,30 @@ def __init__(self, device_config, instrument_handler, experiment_handler): final_voltages = turn_on_voltages, num_points = 200) - - logger.info("Into Barrier-Barrier!") - logger.info(f"{pinch_off_voltages}") logger.info(f"{saturation_voltages}") - new_sat_voltages = [] - - for i in saturation_voltages: - - i += 0.1 - new_sat_voltages.append(i) - working_point, dot_barrier_set_points = self.barrier_barrier_sweep(lower_voltages = pinch_off_voltages, - upper_voltages = new_sat_voltages, + upper_voltages = saturation_voltages, num_points = 200) - sensor_barrier_voltages = list(working_point) + self.sensor_barrier_voltages = list(working_point) - plunger_starting_voltages = [sum(working_point) / len(working_point)] + self.plunger_starting_voltages = [sum(working_point) / len(working_point)] - sensing_point = self.coulomb_blockade_sweep(sensor_barrier_voltages = sensor_barrier_voltages, - lower_voltages = plunger_starting_voltages, - upper_voltages = [1.5], - num_points = 200) + self.sensing_points, (self.best_point, self.best_conductance) = self.coulomb_blockade_sweep(sensor_barrier_voltages = self.sensor_barrier_voltages, + lower_voltages = self.plunger_starting_voltages, + upper_voltages = [1.5], + num_points = 200 + ) - """ self.coulomb_diamonds(lower_sd_voltages = [-0.01], - upper_sd_voltages = [0.01], - lower_plunger_voltages = [0.7], + logger.info(f"{self.sensing_points}") + + self.coulomb_diamonds(lower_sd_voltages = [-0.0002], + upper_sd_voltages = [0.0002], + lower_plunger_voltages = self.plunger_starting_voltages, upper_plunger_voltages = [1.5], - num_points = 100) """ - + num_points = 100) self.dot_plunger_lower_voltages = [] for i in dot_barrier_set_points: @@ -562,12 +553,12 @@ def turn_on(self, ohmic_bias, screening_voltage, gate_voltage, num_points): for i, mean in enumerate(self.means): - turnon_voltage, fig = extract_turn_on_voltage(x_data = turn_on_sweep, - y_data = current_data[i], - noisefloor = current_means[i], - filepath = self.directory, - filename = turn_on_filenames[i], - plot_results = True) + turnon_voltage = extract_turn_on_voltage(x_data = turn_on_sweep, + y_data = current_data[i], + noisefloor = current_means[i], + filepath = self.directory, + filename = turn_on_filenames[i], + plot_results = True) turn_on_voltages.append(turnon_voltage) @@ -650,7 +641,7 @@ def pinch_off(self, gate_voltage, final_voltages, num_points): # Now, we set the voltages on these gates to the the saturation voltages - saturation_voltages = [1.225, 1.225, 1.225, 1.225, 1.15, 1.15, 1.15, 1.15] + saturation_voltages = [1.25, 1.25, 1.25, 1.25, 1.2, 1.2, 1.2, 1.15] pinch_off_dacs_and_vals = {} @@ -719,7 +710,7 @@ def pinch_off(self, gate_voltage, final_voltages, num_points): logger.info("Saturation Voltages Set!") - self.SET_current_check(minimum_current = 2, maximum_current = 4) + self.SET_current_check(minimum_current = 3, maximum_current = 5) # Now that the non-barrier finger gates are set to lower voltages, we perform pinch-offs of the barrier gates for both sides @@ -864,13 +855,17 @@ def pinch_off_individual(self, gate_voltage, final_voltage, gate_name, gate_type pinch_off_sweep = df.iloc[:, 0] data = [df.iloc[:, -2] * self.triple_dot_preamp_sensitivity * 1e9, df.iloc[:, -1] * self.SET_preamp_sensitivity * 1e9] - pinch_off_window, fig = extract_pinch_off_curve_ranges( - x_data = pinch_off_sweep, - y_data = data[noise_floor_idx], - noisefloor = self.means[noise_floor_idx], - filepath = self.directory, - filename = filename2 - ) + gate_type_no_side = gate_type.split()[1] + + logger.info(f"Gate Type: {gate_type_no_side}") + + pinch_off_window = extract_pinch_off_curve_ranges(x_data = pinch_off_sweep, + y_data = data[noise_floor_idx], + noisefloor = self.means[noise_floor_idx], + gate_type = gate_type_no_side, + filepath = self.directory, + filename = filename2 + ) logger.info(f"{pinch_off_window}") @@ -980,7 +975,7 @@ def SET_current_check(self, minimum_current, maximum_current): logger.info(f"New Current Level: {SET_current_level}. Checking Current Level") - time.sleep(2) + time.sleep(10) elif SET_current_level < minimum_current: @@ -1029,7 +1024,7 @@ def SET_current_check(self, minimum_current, maximum_current): logger.info(f"New Current Level: {SET_current_level}. Checking Current Level") - time.sleep(2) + time.sleep(10) logger.info(f"SET Current Level: {SET_current_level}") @@ -1101,7 +1096,7 @@ def barrier_barrier_sweep(self, lower_voltages, upper_voltages, num_points): logger.info("Initial Barrier Voltages Set!") - self.SET_current_check(minimum_current = 2, maximum_current = 4) + self.SET_current_check(minimum_current = 3, maximum_current = 5) # Now, we create the sweep parameters for all the gates @@ -1237,17 +1232,15 @@ def barrier_barrier_sweep(self, lower_voltages, upper_voltages, num_points): filename = filename.removesuffix('.csv') + ".png" - logger.info("file defined...") - - best_point, set_points, perp_traces, fig = extract_working_point(lb_data = lb_data, - rb_data = rb_data, - current_data = current_data, - gates = gates, - DotTuning = "Triple Dot", - barrier_pinch_offs = [lower_voltages[i], lower_voltages[i + 1]], - filepath = self.directory, - filename = filename - ) + best_point, set_points, perp_traces = extract_working_point(lb_data = lb_data, + rb_data = rb_data, + current_data = current_data, + gates = gates, + DotTuning = "Triple Dot", + barrier_pinch_offs = [lower_voltages[i], lower_voltages[i + 1]], + filepath = self.directory, + filename = filename + ) best_points.append(best_point) @@ -1388,22 +1381,22 @@ def barrier_barrier_sweep(self, lower_voltages, upper_voltages, num_points): df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) - lb_data = df.iloc[:, 0] - rb_data = df.iloc[:, 1] + lb_data = df.iloc[:, 1] + rb_data = df.iloc[:, 0] current_data = df.iloc[:, -1] filename = filename.removesuffix('.csv') + ".png" - best_point, working_points, perp_traces, fig = extract_working_point(lb_data = lb_data, - rb_data = rb_data, - current_data = current_data, - gates = gates, - DotTuning = "SET", - barrier_pinch_offs = [lower_voltages[-i -2], lower_voltages[-i - 1]], - filepath = self.directory, - filename = filename - ) + best_point, working_points, perp_traces = extract_working_point(lb_data = lb_data, + rb_data = rb_data, + current_data = current_data, + gates = gates, + DotTuning = "SET", + barrier_pinch_offs = [lower_voltages[-i -2], lower_voltages[-i - 1]], + filepath = self.directory, + filename = filename + ) logger.info(f"Best: {best_point}") logger.info(f"{working_points}") @@ -1449,7 +1442,7 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ sweep_layer = SweepLayer( targets = barrier_targets, - num_points = 20, + num_points = 100, measurement_time = 0.1 ) @@ -1510,7 +1503,7 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ sweep_layer = SweepLayer( targets = charge_sensor_targets, num_points = num_points, - measurement_time = 0.05 + measurement_time = 0.1 ) measure = lambda ih, sp: ( @@ -1561,9 +1554,9 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ if self.device_gates[i]['type'] == 'Sensor Plunger': sweep_layer = SweepLayer( - targets = sensor_plunger_targets[sensor_plunger_idx], + targets = [sensor_plunger_targets[sensor_plunger_idx]], num_points = num_points, - measurement_time = 0.05 + measurement_time = 0.2 ) sensor_plunger_idx += 1 @@ -1590,16 +1583,7 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ filename = filename ) - logger.info("Charge Sensor Plunger Sweep Complete! Finding Sensing Point...") - - for i in self.device_gates: - - if self.device_gates[i]['type'] == "Sensor Plunger": - - gate_name = self.device_gates[i]['label'] - - time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"{gate_name}_{time_str}.csv" + logger.info("Charge Sensor Plunger Sweep Complete! Finding Sensing Point...") filepath = os.path.join(self.directory, filename) @@ -1611,11 +1595,13 @@ def coulomb_blockade_sweep(self, sensor_barrier_voltages, lower_voltages, upper_ filename = filename.removesuffix('.csv') + ".png" - conductance_points = extract_max_conductance_points(x_data = plunger_data, y_data = current_data) - - logger.info(f"{conductance_points}") + conductance_points, (best_point, best_conductance) = extract_max_conductance_points(x_data = plunger_data, + y_data = current_data, + filepath = self.directory, + filename = filename + ) - return conductance_points + return conductance_points, (best_point, best_conductance) def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_voltages, upper_plunger_voltages, num_points): @@ -1637,6 +1623,8 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v wait=True ) + logger.info(f"{sd_dacs_and_vals}") + # We also get our plunger voltages to set them to their lower voltages plunger_dacs_and_vals = {} @@ -1655,6 +1643,8 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v wait=True ) + logger.info(f"{plunger_dacs_and_vals}") + sd_targets = [] lower_ohmic_voltages = [] @@ -1683,6 +1673,8 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v sd_targets.append(sparam) + logger.info(f"{sd_targets}") + sweep_layer = SweepLayer( targets = sd_targets, num_points = 100, @@ -1723,6 +1715,8 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v plunger_targets.append(sparam) + logger.info(f"{plunger_targets}") + sweep_layer = SweepLayer( targets = plunger_targets, num_points = 100, @@ -1756,16 +1750,24 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v sensor_ohmic_idx = 0 + sensor_plunger_names = [] + + sensor_ohmic_names = [] + for i in self.gates_to_dacs: if self.device_gates[i]['type'] == "Sensor Plunger": p = self.device_gates[i]['channel'] + name = self.device_gates[i]["label"] + + sensor_plunger_names.append(name) + sparam = SweepParam( parameter = p, - start = upper_plunger_voltages[sensor_plunger_idx], - end = lower_plunger_voltages[sensor_plunger_idx] + start = lower_plunger_voltages[sensor_plunger_idx], + end = upper_plunger_voltages[sensor_plunger_idx] ) sensor_plunger_targets.append(sparam) @@ -1776,6 +1778,10 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v p = self.device_gates[i]['channel'] + name = self.device_gates[i]["label"] + + sensor_ohmic_names.append(name) + sparam = SweepParam( parameter = p, start = lower_ohmic_voltages[sensor_ohmic_idx], @@ -1795,7 +1801,7 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v ) sweep_layer_2 = SweepLayer( - targets = [sensor_plunger_targets[i]], + targets = [item], num_points = num_points, measurement_time = 0.2 ) @@ -1823,100 +1829,99 @@ def coulomb_diamonds(self, lower_sd_voltages, upper_sd_voltages, lower_plunger_v ) future = self.experiment_handler.do_sweep(sweep = sweep, - instrument_handler = self.instrument_handler) + instrument_handler = self.instrument_handler, + filename = filename + ) logger.info("Coulomb Diamond Sweep Complete! Finding Diamonds...") -class GlobalChargeTuning(Protocol, Bootstrapping): +class GlobalChargeTuning(Bootstrapping): def __init__(self, device_config, instrument_handler, experiment_handler): - - super().__init__(device_config = device_config, instrument_handler = instrument_handler, experiment_handler = experiment_handler) - confirmation = self.confirm_charge_transitions(lower_plunger_voltages = self.dot_plunger_lower_voltages, upper_plunger_voltages = [1.5, 1.5, 1.5], num_points = 200) + Protocol.__init__(self, + device_config=device_config, + instrument_handler=instrument_handler, + experiment_handler=experiment_handler + ) - logger.info(f"{confirmation}") + self.plunger_starting_voltages = [1.1674690763420748] - def calibrate_countersweeping(self, lower_dot_plunger_voltages, upper_dot_plunger_voltages, lower_sensor_plunger_voltages, upper_sensor_plunger_voltages, num_points): + self.plunger_ending_voltages = [1.5] - # First, we get the current plunger gate voltages + self.dot_plunger_lower_voltages = [1.2587939698492463 - 0.02, 1.2814070351758793 - 0.02, 1.2587939698492463 - 0.02] - dot_plunger_dacs_and_vals = {} + self.dot_plunger_idle_voltages = [1.2587939698492463, 1.2814070351758793, 1.2587939698492463] - sensor_plunger_dacs_and_vals = {} + self.dot_plunger_upper_voltages = [1.2587939698492463 + 0.02, 1.2814070351758793 + 0.02, 1.2587939698492463 + 0.02] - for i in self.gates_to_dacs: + self.plunger_crosstalk_vals = self.calibrate_countersweeping(lower_dot_plunger_voltages = self.dot_plunger_lower_voltages, + upper_dot_plunger_voltages = self.dot_plunger_upper_voltages, + num_points = 150 + ) + + logger.info(f"{self.plunger_crosstalk_vals}") - if self.device_gates[i]['type'] == 'Dot Plunger': + confirmation = self.confirm_charge_transitions(lower_plunger_voltages = self.dot_plunger_lower_voltages, + upper_plunger_voltages = [1.5, 1.5, 1.5], + plunger_crosstalk_vals = self.plunger_crosstalk_vals, + num_points = 400 + ) - p = self.device_gates[i]['channel'] + logger.info(f"{confirmation}") - instr, param = p.split('.', 1) + self.dot_plunger_lower_voltages = [1.2587939698492463 - 0.12, 1.2814070351758793 - 0.12, 1.2587939698492463 - 0.12] - dot_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( - instr, - param, - wait=True - ) + self.plunger_crosstalk_vals = [np.float64(-3.0840343159529215 - 0.5), np.float64(-3.948856914488276 - 0.5), np.float64(-5.174829249744017 - 0.5)] + + self.plunger_plunger_sweep(lower_plunger_voltages = self.dot_plunger_lower_voltages, + idle_plunger_voltages = self.dot_plunger_idle_voltages, + upper_plunger_voltages = [1.5, 1.5, 1.5], + plunger_crosstalk_vals = self.plunger_crosstalk_vals, + num_points = 200 + ) + + def recalibrate_charge_sensors(self, lower_voltages, upper_voltages, num_points): + + # First, we set our charge sensor plunger gates to their initial values + + charge_sensor_dacs_and_vals = {} - elif self.device_gates[i]['type'] == "Sensor Plunger": + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": p = self.device_gates[i]['channel'] instr, param = p.split('.', 1) - sensor_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + charge_sensor_dacs_and_vals[i] = self.instrument_handler.get_parameter( instr, param, wait=True ) - - logger.info(f"{dot_plunger_dacs_and_vals}") - - logger.info(f"{sensor_plunger_dacs_and_vals}") - - # Now, we set our dot and sensor plungers to their initial values - dot_plunger_targets = [] - - sensor_plunger_targets = [] - - endpoint_iter_dot = iter(lower_dot_plunger_voltages) - - endpoint_iter_sensor = iter(lower_sensor_plunger_voltages) - - for gate, dac_and_val in dot_plunger_dacs_and_vals.items(): - for dac, starting_val in dac_and_val.items(): - - p = "spi_rack." + dac - - dot_plunger_val = next(endpoint_iter_dot) - - sparam = SweepParam( - parameter = p, - start = starting_val, - end = dot_plunger_val - ) + charge_sensor_targets = [] - dot_plunger_targets.append(sparam) + endpoint_iter = iter(lower_voltages) - for gate, dac_and_val in sensor_plunger_dacs_and_vals.items(): + for gate, dac_and_val in charge_sensor_dacs_and_vals.items(): for dac, starting_val in dac_and_val.items(): p = "spi_rack." + dac - dot_plunger_val = next(endpoint_iter_sensor) + sensor_plunger_val = next(endpoint_iter) sparam = SweepParam( parameter = p, start = starting_val, - end = dot_plunger_val + end = sensor_plunger_val ) - sensor_plunger_targets.append(sparam) + charge_sensor_targets.append(sparam) sweep_layer = SweepLayer( - targets = dot_plunger_targets + sensor_plunger_targets, + targets = charge_sensor_targets, num_points = num_points, measurement_time = 0.1 ) @@ -1931,81 +1936,51 @@ def calibrate_countersweeping(self, lower_dot_plunger_voltages, upper_dot_plunge sweep = Sweep([sweep_layer], measure) - logger.info("Setting Initial Plunger Voltages...") + logger.info("Setting Initial Charge Sensor Plunger Voltages...") future = self.experiment_handler.set_voltage_configuration(sweep = sweep, instrument_handler = self.instrument_handler) - logger.info("Initial Plunger Voltages Set!") - - # Now, we construct 2D scans, in which the Sensor plunger is swept and the dot plungers are stepped + logger.info("Initial Charge Sensor Plunger Voltages Set!") - dot_plunger_targets = [] + # Now, we define the sensor plunger sweeps sensor_plunger_targets = [] - dot_plunger_idx = 0 - sensor_plunger_idx = 0 - dot_plunger_names = [] - - sensor_plunger_names = [] - for i in self.gates_to_dacs: - - if self.device_gates[i]['type'] == 'Dot Plunger': + + if self.device_gates[i]['type'] == "Sensor Plunger": p = self.device_gates[i]['channel'] - gate_name = self.device_gates[i]['label'] - - dot_plunger_names.append(gate_name) - sparam = SweepParam( parameter = p, - start = lower_dot_plunger_voltages[dot_plunger_idx], - end = upper_dot_plunger_voltages[dot_plunger_idx] + start = lower_voltages[sensor_plunger_idx], + end = upper_voltages[sensor_plunger_idx] ) - dot_plunger_idx += 1 - - elif self.device_gates[i]['type'] == 'Sensor Plunger': + sensor_plunger_targets.append(sparam) - p = self.device_gates[i]['channel'] + sensor_plunger_idx += 1 + + logger.info(f"{sensor_plunger_targets}") - gate_name = self.device_gates[i]['label'] + sensor_plunger_idx = 0 - sensor_plunger_names.append(gate_name) + for i in self.gates_to_dacs: - sparam = SweepParam( - parameter = p, - start = lower_sensor_plunger_voltages[sensor_plunger_idx], - end = upper_sensor_plunger_voltages[sensor_plunger_idx] + if self.device_gates[i]['type'] == 'Sensor Plunger': + + sweep_layer = SweepLayer( + targets = [sensor_plunger_targets[sensor_plunger_idx]], + num_points = num_points, + measurement_time = 0.2 ) sensor_plunger_idx += 1 - crosstalk_vals = [] - - for i, sensor in enumerate(sensor_plunger_targets): - - sensor_layer = SweepLayer(targets = [sensor], - num_points = num_points, - measurement_time = 0.1 - ) - - sensor_name = sensor_plunger_names[i] - - for j, dot in enumerate(dot_plunger_targets): - - dot_layer = SweepLayer(targets = [dot], - num_points = num_points, - measurement_time = 0.1 - ) - - dot_name = dot_plunger_names[j] - measure = lambda ih, sp: ( ih.read_buffer([ 'agilent_left.volt', @@ -2014,31 +1989,45 @@ def calibrate_countersweeping(self, lower_dot_plunger_voltages, upper_dot_plunge ['agilent_left.volt', 'agilent_right.volt'] ) - sweep = Sweep([dot_layer, sensor_layer], measure) + sweep = Sweep([sweep_layer], measure) - time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"{sensor_name}_{dot_name}_Scan_{time_str}.csv" + logger.info("Charge Sensor Plunger Sweep Starting...") - logger.info(f"Determining coupling between {sensor_name} and {dot_name}...") + gate_name = self.device_gates[i]['label'] + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name}_Sweep_{time_str}.csv" future = self.experiment_handler.do_sweep(sweep = sweep, instrument_handler = self.instrument_handler, filename = filename ) - logger.info("Scan Complete! Finding cross-talk coefficient...") + logger.info("Charge Sensor Plunger Sweep Complete! Finding Sensing Point...") + filepath = os.path.join(self.directory, filename) + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) - return crosstalk_vals + plunger_data = df.iloc[:, 0] - def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_voltages, sensing_points, num_points): + current_data = df.iloc[:, -1] - # First, we get the currnet plunger gate voltages + filename = filename.removesuffix('.csv') + ".png" - dot_plunger_dacs_and_vals = {} + conductance_points = extract_max_conductance_pair(x_data = plunger_data, + y_data = current_data, + filepath = self.directory, + filename = filename + ) - sensor_plunger_dacs_and_vals = {} + return conductance_points + + def calibrate_countersweeping(self, lower_dot_plunger_voltages, upper_dot_plunger_voltages, num_points): + + # First, we get the current plunger gate voltages + + dot_plunger_dacs_and_vals = {} for i in self.gates_to_dacs: @@ -2054,31 +2043,13 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta wait=True ) - elif self.device_gates[i]['type'] == 'Sensor Plunger': - - p = self.device_gates[i]['channel'] - - instr, param = p.split('.', 1) - - sensor_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( - instr, - param, - wait=True - ) - logger.info(f"{dot_plunger_dacs_and_vals}") - logger.info(f"{sensor_plunger_dacs_and_vals}") + # Now, we set our dot plungers to their initial values - # Now, we set our plungers to their initial values - dot_plunger_targets = [] - sensor_plunger_targets = [] - - endpoint_iter_dot = iter(lower_plunger_voltages) - - endpoint_iter_sensor = iter(sensing_points) + endpoint_iter_dot = iter(lower_dot_plunger_voltages) for gate, dac_and_val in dot_plunger_dacs_and_vals.items(): for dac, starting_val in dac_and_val.items(): @@ -2095,6 +2066,62 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta dot_plunger_targets.append(sparam) + sweep_layer = SweepLayer( + targets = dot_plunger_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Initial Dot Plunger Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Dot Plunger Voltages Set!") + + # Now, we recalibrate the charge sensor + + sensing_points = self.recalibrate_charge_sensors(lower_voltages = self.plunger_starting_voltages, + upper_voltages = self.plunger_ending_voltages, + num_points = 200 + ) + + sensor_plunger_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + sensor_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + logger.info(f"{sensor_plunger_dacs_and_vals}") + + sensor_plunger_targets = [] + + sensor_lower_bound = [sensing_points[0] - 0.01] + + sensor_upper_bound = [sensing_points[1] + 0.01] + + endpoint_iter_sensor = iter(sensor_lower_bound) + for gate, dac_and_val in sensor_plunger_dacs_and_vals.items(): for dac, starting_val in dac_and_val.items(): @@ -2108,10 +2135,10 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta end = sensor_plunger_val ) - sensor_plunger_targets.append(sparam) + sensor_plunger_targets.append(sparam) sweep_layer = SweepLayer( - targets = dot_plunger_targets + sensor_plunger_targets, + targets = sensor_plunger_targets, num_points = num_points, measurement_time = 0.1 ) @@ -2126,18 +2153,26 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta sweep = Sweep([sweep_layer], measure) - logger.info("Setting Initial Plunger Voltages...") + logger.info("Setting Charge Sensor to Newly Calibrated Point...") future = self.experiment_handler.set_voltage_configuration(sweep = sweep, instrument_handler = self.instrument_handler) - logger.info("Initial Plunger Voltages Set!") + logger.info("Charge Sensor Calibrated!") - # Now, we sweep our plungers and read the SET current to determine if we can sense charge transitions + # Now, we construct 2D scans, in which the Sensor plunger is swept and the dot plungers are stepped and the return sweeps - confirmations = [] + dot_plunger_targets = [] - dot_plunger_idx = 0 + sensor_plunger_targets = [] + + dot_plunger_idx = 0 + + sensor_plunger_idx = 0 + + dot_plunger_names = [] + + sensor_plunger_names = [] for i in self.gates_to_dacs: @@ -2147,20 +2182,102 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta gate_name = self.device_gates[i]['label'] + dot_plunger_names.append(gate_name) + sparam = SweepParam( parameter = p, - start = lower_plunger_voltages[dot_plunger_idx], - end = upper_plunger_voltages[dot_plunger_idx] + start = lower_dot_plunger_voltages[dot_plunger_idx], + end = upper_dot_plunger_voltages[dot_plunger_idx] ) + dot_plunger_targets.append(sparam) + dot_plunger_idx += 1 - sweep_layer = SweepLayer( - targets = [sparam], - num_points = num_points, - measurement_time = 0.2 + elif self.device_gates[i]['type'] == 'Sensor Plunger': + + p = self.device_gates[i]['channel'] + + gate_name = self.device_gates[i]['label'] + + sensor_plunger_names.append(gate_name) + + sparam = SweepParam( + parameter = p, + start = sensor_lower_bound[sensor_plunger_idx], + end = sensor_upper_bound[sensor_plunger_idx] + ) + + sensor_plunger_targets.append(sparam) + + sensor_plunger_idx += 1 + + # Here we define the return sweeps + + dot_return_targets = [] + + sensor_return_targets = [] + + endpoint_iter_dot = iter(upper_dot_plunger_voltages) + + endpoint_iter_sensor = iter(sensor_upper_bound) + + startpoint_iter_sensor = iter(sensor_lower_bound) + + for gate, dac_and_val in dot_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + dot_plunger_val = next(endpoint_iter_dot) + + sparam = SweepParam( + parameter = p, + start = dot_plunger_val, + end = starting_val + ) + + dot_return_targets.append(sparam) + + for gate, dac_and_val in sensor_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + start_sensor_plunger_val = next(endpoint_iter_sensor) + + end_sensor_plunger_val = next(startpoint_iter_sensor) + + sparam = SweepParam( + parameter = p, + start = start_sensor_plunger_val, + end = end_sensor_plunger_val ) + sensor_return_targets.append(sparam) + + # Now, we run the sweeps + + crosstalk_vals = [] + + for i, sensor in enumerate(sensor_plunger_targets): + + sensor_layer = SweepLayer(targets = [sensor], + num_points = num_points, + measurement_time = 0.2 + ) + + sensor_name = sensor_plunger_names[i] + + for j, dot in enumerate(dot_plunger_targets): + + dot_layer = SweepLayer(targets = [dot], + num_points = 20, + measurement_time = 0.2 + ) + + dot_name = dot_plunger_names[j] + measure = lambda ih, sp: ( ih.read_buffer([ 'agilent_left.volt', @@ -2169,118 +2286,185 @@ def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_volta ['agilent_left.volt', 'agilent_right.volt'] ) - sweep = Sweep([sweep_layer], measure) - - logger.info(f"Confirming Charge Transition detection for {gate_name}...") + sweep = Sweep([dot_layer, sensor_layer], measure) time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"{gate_name}_Sweep_{time_str}.csv" + filename = f"{sensor_name}_{dot_name}_Scan_{time_str}.csv" + + logger.info(f"Determining coupling between {sensor_name} and {dot_name}...") future = self.experiment_handler.do_sweep(sweep = sweep, instrument_handler = self.instrument_handler, filename = filename ) + + logger.info("Scan Complete! Finding cross-talk coefficient...") - logger.info(f"{gate_name} Sweep Complete! Confirming Transition Detection...") + filepath = os.path.join(self.directory, filename) + + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) + + sensor_data = df.iloc[:, 1] + dot_data = df.iloc[:, 0] + current_data = df.iloc[:, -1] + + filename = filename.removesuffix('.csv') + ".png" + + slope, intercept = hough_transform(x_data = sensor_data, + y_data = dot_data, + current_data = current_data, + filepath = self.directory, + filename = filename + ) - confirmations.append() + crosstalk_vals.append(slope) + logger.info("Returning plungers to the original points...") - return confirmations + return_layer = SweepLayer(targets = [dot_return_targets[j], sensor_return_targets[i]], + num_points = num_points, + measurement_time = 0.1 + ) - def tune_lead_dot_tunneling(self, lower_barrier_voltages, upper_barrier_voltages, lower_plunger_voltages, upper_plunger_voltages, num_points): - - # First, get the lead-barrier voltages, and the dot plunger voltages + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) - outer_plunger_dacs_and_vals = {} + sweep = Sweep([return_layer], measure) - lead_barrier_dacs_and_vals = {} + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler + ) - plunger_idx = 0 + return crosstalk_vals - barrier_idx = 0 + def confirm_charge_transitions(self, lower_plunger_voltages, upper_plunger_voltages, plunger_crosstalk_vals, num_points): + + # First, we get the current dot plunger gate voltages + + dot_plunger_dacs_and_vals = {} for i in self.gates_to_dacs: if self.device_gates[i]['type'] == 'Dot Plunger': - if plunger_idx == 0 or plunger_idx == 2: + p = self.device_gates[i]['channel'] - p = self.device_gates[i]['channel'] + instr, param = p.split('.', 1) - instr, param = p.split('.', 1) + dot_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) - outer_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( - instr, - param, - wait=True - ) - - plunger_idx += 1 + logger.info(f"{dot_plunger_dacs_and_vals}") - elif self.device_gates[i]['type'] == 'Dot Barrier': + # Now, we set our dot plungers to their initial values + + dot_plunger_targets = [] - if barrier_idx == 0 or barrier_idx == 3: + endpoint_iter_dot = iter(lower_plunger_voltages) - p = self.device_gates[i]['channel'] + for gate, dac_and_val in dot_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): - instr, param = p.split('.', 1) + p = "spi_rack." + dac - lead_barrier_dacs_and_vals[i] = self.instrument_handler.get_parameter( - instr, - param, - wait=True - ) - - barrier_idx +=1 + dot_plunger_val = next(endpoint_iter_dot) - logger.info(f"{outer_plunger_dacs_and_vals}") + sparam = SweepParam( + parameter = p, + start = starting_val, + end = dot_plunger_val + ) - logger.info(f"{lead_barrier_dacs_and_vals}") + dot_plunger_targets.append(sparam) - # Now, we set our plungers and barriers to their starting values + sweep_layer = SweepLayer( + targets = dot_plunger_targets, + num_points = num_points, + measurement_time = 0.1 + ) - plunger_targets = [] + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) - barrier_targets = [] + sweep = Sweep([sweep_layer], measure) - endpoint_iter_plunger = iter(lower_plunger_voltages) + logger.info("Setting Initial Dot Plunger Voltages...") - endpoint_iter_barrier = iter(lower_barrier_voltages) + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) - for gate, dac_and_val in outer_plunger_dacs_and_vals.items(): - for dac, starting_val in dac_and_val.items(): + logger.info("Initial Dot Plunger Voltages Set!") - p = "spi_rack." + dac + # Now, we recalibrate our charge sensor - plunger_val = next(endpoint_iter_plunger) + sensing_points = self.recalibrate_charge_sensors(lower_voltages = self.plunger_starting_voltages, + upper_voltages = self.plunger_ending_voltages, + num_points = 200 + ) + + logger.info(f"Sensing Points: {sensing_points}") - sparam = SweepParam( - parameter = p, - start = starting_val, - end = plunger_val - ) + sensor_plunger_dacs_and_vals = {} - plunger_targets.append(sparam) + sensor_parameters = [] - for gate, dac_and_val in lead_barrier_dacs_and_vals.items(): + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + sensor_parameters.append(p) + + instr, param = p.split('.', 1) + + sensor_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + sensor_plunger_targets = [] + + sensing_point = sensing_points[0] + + logger.info(f"Sensing Point: {sensing_point}") + + sensing_point_list = [sensing_point] + + endpoint_iter_sensor = iter(sensing_point_list) + + for gate, dac_and_val in sensor_plunger_dacs_and_vals.items(): for dac, starting_val in dac_and_val.items(): p = "spi_rack." + dac - barrier_val = next(endpoint_iter_barrier) + sensor_plunger_val = next(endpoint_iter_sensor) sparam = SweepParam( parameter = p, start = starting_val, - end = barrier_val + end = sensor_plunger_val ) - barrier_targets.append(sparam) + sensor_plunger_targets.append(sparam) sweep_layer = SweepLayer( - targets = plunger_targets + barrier_targets, + targets = sensor_plunger_targets, num_points = num_points, measurement_time = 0.1 ) @@ -2295,50 +2479,299 @@ def tune_lead_dot_tunneling(self, lower_barrier_voltages, upper_barrier_voltages sweep = Sweep([sweep_layer], measure) - logger.info("Setting Initial Lead Voltages...") + logger.info("Setting Charge Sensor to Newly Calibrated Point...") future = self.experiment_handler.set_voltage_configuration(sweep = sweep, instrument_handler = self.instrument_handler) - logger.info("Initial Lead Voltages Set!") + logger.info("Charge Sensor Calibrated!") - # Now, we create our sweeps + # Now, we determine the voltage ranges over which our sensor plungers wil be counterswept - plunger_targets = [] + plunger_crosstalk_vals = [np.float64(-3.0840343159529215 - 0.5), np.float64(-3.948856914488276 - 0.5), np.float64(-5.174829249744017 - 0.5)] - barrier_targets = [] + final_sensor_voltages = [] - plunger_idx = 0 + for i, slope in enumerate(plunger_crosstalk_vals): - barrier_idx = 0 + dot_step = (upper_plunger_voltages[i] - lower_plunger_voltages[i]) / num_points - plunger_names = [] + sensor_step = dot_step / slope - barrier_names = [] + sensor_endpoint = sensing_point + (sensor_step * num_points) - startpoint_iter_plunger = iter(lower_plunger_voltages) + logger.info(f"sensor endpoint: {sensor_endpoint} type: {type(sensor_endpoint)}") - endpoint_iter_plunger = iter(upper_plunger_voltages) + final_sensor_voltages.append(float(sensor_endpoint.item())) - startpoint_iter_barrier = iter(lower_barrier_voltages) + logger.info(f"Final Sensor Voltages: {final_sensor_voltages}") - endpoint_iter_barrier = iter(upper_barrier_voltages) + # Now, we sweep our dot plungers and read the SET current to determine if we can sense charge transitions + + confirmations = [] + + dot_plunger_idx = 0 for i in self.gates_to_dacs: if self.device_gates[i]['type'] == 'Dot Plunger': - if plunger_idx == 0 or plunger_idx == 2: + p = self.device_gates[i]['channel'] - p = self.device_gates[i]['channel'] + gate_name = self.device_gates[i]['label'] - gate_name = self.device_gates[i]['label'] + sparam = SweepParam( + parameter = p, + start = lower_plunger_voltages[dot_plunger_idx], + end = upper_plunger_voltages[dot_plunger_idx] + ) - plunger_names.append(gate_name) + logger.info(f"sparam: {sparam}") - start_val = next(startpoint_iter_plunger) + sensorparam = SweepParam( + parameter = sensor_parameters[0], + start = sensing_point, + end = final_sensor_voltages[dot_plunger_idx] + ) - end_val = next(endpoint_iter_plunger) + logger.info(f"sensorparam: {sensorparam}") + + sweep_layer = SweepLayer( + targets = [sparam, sensorparam], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info(f"Confirming Charge Transition detection for {gate_name}...") + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name}_Sweep_{time_str}.csv" + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename + ) + + logger.info(f"{gate_name} Sweep Complete! Confirming Transition Detection...") + + filepath = os.path.join(self.directory, filename) + + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) + + plunger_data = df.iloc[:, 0] + + current_data = df.iloc[:, -1] + + filename = filename.removesuffix('.csv') + ".png" + + conductance_points = extract_max_conductance_points(x_data = plunger_data, + y_data = current_data, + filepath = self.directory, + filename = filename + ) + + confirmations.append(1) + + # Now, we define our return sweeps and reset our plunger gates + + return_sparam = SweepParam( + parameter = p, + start = upper_plunger_voltages[dot_plunger_idx], + end = lower_plunger_voltages[dot_plunger_idx] + ) + + return_sensorparam = SweepParam( + parameter = sensor_parameters[0], + start = final_sensor_voltages[dot_plunger_idx], + end = sensing_point + ) + + dot_plunger_idx += 1 + + return_sweep_layer = SweepLayer( + targets = [return_sparam, return_sensorparam], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([return_sweep_layer], measure) + + logger.info("Returning to starting voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + + return confirmations + + def tune_lead_dot_tunneling(self, lower_barrier_voltages, upper_barrier_voltages, lower_plunger_voltages, upper_plunger_voltages, num_points): + + # First, get the lead-barrier voltages, and the dot plunger voltages + + outer_plunger_dacs_and_vals = {} + + lead_barrier_dacs_and_vals = {} + + plunger_idx = 0 + + barrier_idx = 0 + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + if plunger_idx == 0 or plunger_idx == 2: + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + outer_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + plunger_idx += 1 + + elif self.device_gates[i]['type'] == 'Dot Barrier': + + if barrier_idx == 0 or barrier_idx == 3: + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + lead_barrier_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + barrier_idx +=1 + + logger.info(f"{outer_plunger_dacs_and_vals}") + + logger.info(f"{lead_barrier_dacs_and_vals}") + + # Now, we set our plungers and barriers to their starting values + + plunger_targets = [] + + barrier_targets = [] + + endpoint_iter_plunger = iter(lower_plunger_voltages) + + endpoint_iter_barrier = iter(lower_barrier_voltages) + + for gate, dac_and_val in outer_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + plunger_val = next(endpoint_iter_plunger) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = plunger_val + ) + + plunger_targets.append(sparam) + + for gate, dac_and_val in lead_barrier_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + barrier_val = next(endpoint_iter_barrier) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = barrier_val + ) + + barrier_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = plunger_targets + barrier_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Initial Lead Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Lead Voltages Set!") + + # Now, we create our sweeps + + plunger_targets = [] + + barrier_targets = [] + + plunger_idx = 0 + + barrier_idx = 0 + + plunger_names = [] + + barrier_names = [] + + startpoint_iter_plunger = iter(lower_plunger_voltages) + + endpoint_iter_plunger = iter(upper_plunger_voltages) + + startpoint_iter_barrier = iter(lower_barrier_voltages) + + endpoint_iter_barrier = iter(upper_barrier_voltages) + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + if plunger_idx == 0 or plunger_idx == 2: + + p = self.device_gates[i]['channel'] + + gate_name = self.device_gates[i]['label'] + + plunger_names.append(gate_name) + + start_val = next(startpoint_iter_plunger) + + end_val = next(endpoint_iter_plunger) sparam = SweepParam( parameter = p, @@ -2414,11 +2847,361 @@ def tune_lead_dot_tunneling(self, lower_barrier_voltages, upper_barrier_voltages return barrier_setpoints - def plunger_plunger_sweep(): + def plunger_plunger_sweep(self, lower_plunger_voltages, idle_plunger_voltages, upper_plunger_voltages, plunger_crosstalk_vals, num_points): - # First, we get the current + # First, we get the current dot plunger gate voltages - pass + dot_plunger_dacs_and_vals = {} + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == 'Dot Plunger': + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + dot_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + logger.info(f"{dot_plunger_dacs_and_vals}") + + # Now, we set our dot plungers to their initial values + + dot_plunger_targets = [] + + endpoint_iter_dot = iter(idle_plunger_voltages) + + for gate, dac_and_val in dot_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + dot_plunger_val = next(endpoint_iter_dot) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = dot_plunger_val + ) + + dot_plunger_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = dot_plunger_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Initial Dot Plunger Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Dot Plunger Voltages Set!") + + # Now, we recalibrate our charge sensor + + sensing_points = self.recalibrate_charge_sensors(lower_voltages = self.plunger_starting_voltages, + upper_voltages = self.plunger_ending_voltages, + num_points = 200 + ) + + logger.info(f"Sensing Points: {sensing_points}") + + sensor_plunger_dacs_and_vals = {} + + sensor_parameters = [] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] == "Sensor Plunger": + + p = self.device_gates[i]['channel'] + + sensor_parameters.append(p) + + instr, param = p.split('.', 1) + + sensor_plunger_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + sensor_plunger_targets = [] + + sensing_point = sensing_points[0] + + logger.info(f"Sensing Point: {sensing_point}") + + sensing_point_list = [sensing_point] + + endpoint_iter_sensor = iter(sensing_point_list) + + for gate, dac_and_val in sensor_plunger_dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + p = "spi_rack." + dac + + sensor_plunger_val = next(endpoint_iter_sensor) + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = sensor_plunger_val + ) + + sensor_plunger_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = sensor_plunger_targets, + num_points = num_points, + measurement_time = 0.1 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer], measure) + + logger.info("Setting Charge Sensor to Newly Calibrated Point...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Charge Sensor Calibrated!") + + # Now, we construct the 2D plunger scans, compensating for the inner layer with the charge sensor + + plunger_targets_dots = [] + + plunger_return_dots = [] + + plunger_dot_parameters = [] + + plunger_dot_names = [] + + plunger_idx = 0 + + for item in self.gates_to_dacs: + + if self.device_gates[item]['type'] == "Dot Plunger": + + p = self.device_gates[item]['channel'] + + plunger_dot_parameters.append(p) + + name = self.device_gates[item]["label"] + + plunger_dot_names.append(name) + + sparam = SweepParam( + parameter = p, + start = lower_plunger_voltages[plunger_idx], + end = upper_plunger_voltages[plunger_idx] + ) + + return_sparam = SweepParam( + parameter = p, + start = upper_plunger_voltages[plunger_idx], + end = idle_plunger_voltages[plunger_idx] + ) + + plunger_targets_dots.append(sparam) + + plunger_return_dots.append(return_sparam) + + plunger_idx += 1 + + logger.info(f"{plunger_targets_dots}") + logger.info(f"{plunger_return_dots}") + logger.info(f"{plunger_dot_parameters}") + logger.info(f"{plunger_dot_names}") + + # Here, we define the sensor compensation sweeps + + final_sensor_voltages = [] + + for i, slope in enumerate(plunger_crosstalk_vals): + + dot_step = (upper_plunger_voltages[i] - lower_plunger_voltages[i]) / num_points + + sensor_step = dot_step / slope + + sensor_endpoint = sensing_point + (sensor_step * num_points) + + logger.info(f"sensor endpoint: {sensor_endpoint} type: {type(sensor_endpoint)}") + + final_sensor_voltages.append(float(sensor_endpoint.item())) + + logger.info(f"Final Sensor Voltages: {final_sensor_voltages}") + + plunger_targets_sensors = [] + + plunger_return_sensors = [] + + plunger_idx = 0 + + for item in self.gates_to_dacs: + + if self.device_gates[item]['type'] == "Dot Plunger": + + p = sensor_parameters[0] + + sparam = SweepParam( + parameter = p, + start = sensing_point, + end = final_sensor_voltages[plunger_idx] + ) + + return_sparam = SweepParam( + parameter = p, + start = final_sensor_voltages[plunger_idx], + end = sensing_point + ) + + plunger_targets_sensors.append(sparam) + + plunger_return_sensors.append(return_sparam) + + plunger_idx += 1 + + logger.info(f"{plunger_targets_sensors}") + logger.info(f"{plunger_return_sensors}") + + # Now, we perform the plunger-plunger scans + + for i, (first, second, ret_first, ret_second) in enumerate(zip(plunger_targets_dots, + plunger_targets_dots[1:], + plunger_return_dots, + plunger_return_dots[1:] + ) + ): + + # Here, we set the involved dot plungers to the lower plunger value from their idle values + + lower_param_1 = SweepParam( + parameter = plunger_dot_parameters[i], + start = idle_plunger_voltages[i], + end = lower_plunger_voltages[i] + ) + + lower_param_2 = SweepParam( + parameter = plunger_dot_parameters[i + 1], + start = idle_plunger_voltages[i + 1], + end = lower_plunger_voltages[i + 1] + ) + + lower_layer = SweepLayer( + targets = [lower_param_1, lower_param_2], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + lower_sweep = Sweep([lower_layer], measure) + + logger.info("Setting Plungers to their initial sweep values...") + + future = self.experiment_handler.set_voltage_configuration(sweep = lower_sweep, + instrument_handler = self.instrument_handler) + + logger.info("Initial Sweep Voltages Set!") + + # Now, we perform the actual sweep + + sweep_layer_1 = SweepLayer( + targets = [first], + num_points = num_points, + measurement_time = 0.2 + ) + + sweep_layer_2 = SweepLayer( + targets = [second], + num_points = num_points, + measurement_time = 0.2 + ) + + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) + + sweep = Sweep([sweep_layer_2, sweep_layer_1], measure) + + logger.info("Dot Plunger-Plunger Scan Starting...") + + gate_name_1 = plunger_dot_names[i] + gate_name_2 = plunger_dot_names[i + 1] + + gates = [gate_name_1, gate_name_2] + + time_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{gate_name_1}_{gate_name_2}_Scan_{time_str}.csv" + + future = self.experiment_handler.do_sweep(sweep = sweep, + instrument_handler = self.instrument_handler, + filename = filename) + + # Here, we find the set points for the dot barrier-barrier scans + + logger.info("Dot Plunger Scan Complete! Plotting CSD...") + + filepath = os.path.join(self.directory, filename) + + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) + + lb_data = df.iloc[:, 0] + rb_data = df.iloc[:, 1] + + current_data = df.iloc[:, -2] + + filename = filename.removesuffix('.csv') + ".png" + + # Now, we reset the barriers back to their starting voltages + + return_layer = SweepLayer( + targets = [ret_first, ret_second], + num_points = num_points, + measurement_time = 0.1 + ) + + return_sweep = Sweep([return_layer], measure) + + logger.info("Returning Plungers to starting values...") + + future = self.experiment_handler.set_voltage_configuration(sweep = return_sweep, + instrument_handler = self.instrument_handler) + class VirtualGating(Protocol): diff --git a/src/data_analysis.py b/src/data_analysis.py index 769aa81..c8baec7 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -174,8 +174,6 @@ def extract_turn_on_voltage(x_data: np.array, if val > abs(threshold): idx_turnon = np.where(y1 == val)[0][0] # get the index of the turn-on point - logger.info(f"Index: {idx_turnon}") - turnon_voltage = x1[idx_turnon - 1] logger.info(f"Turn_On Voltage: {turnon_voltage}") @@ -201,6 +199,8 @@ def extract_turn_on_voltage(x_data: np.array, ax.set_xlabel(r'V$_{gate}$ (V)', fontsize=35) ax.set_ylabel('I (nA)', fontsize=35) + logger.info("before minor ticks!") + ax.minorticks_on() ax.tick_params(which='minor', direction='in', length=3, top=True, right=True) ax.tick_params(direction='in', length=5, width=1.2, labelsize=18, top=True, right=True) @@ -215,11 +215,17 @@ def extract_turn_on_voltage(x_data: np.array, ax.set_yticks(yticks_span) ax.set_yticklabels([f'{np.abs(yticks_span[0]):.1f}', '', '', '', f'{yticks_span[-1]:.4f}'], fontsize=25) + logger.info("before tight layout!") + plt.tight_layout() + logger.info("before save!") + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') + logger.info("before close!") + plt.close(fig) # --- Print summary --- @@ -350,9 +356,9 @@ def extract_pinch_off_curve_ranges(x_data: np.array, break elif gate_type == 'Barrier': - sat_voltage = fit_midpoint_voltage + sat_voltage = fit_saturation_voltage sat_current = y1_norm[np.argmax(np.isclose(x1, sat_voltage, atol=1e-3, rtol=1e-3))] - sat_label = 'Midpoint' + sat_label = 'Saturation Point' else: raise TypeError("The gate_type given isn't one of the following: 'Accumulation', 'Plunger', 'Barrier'") @@ -451,6 +457,8 @@ def extract_pinch_off_curve_ranges(x_data: np.array, def extract_max_conductance_points(x_data: np.array, y_data: np.array, + filepath: str, + filename: str, peak_height: list[float] = [None, None], peak_prominence: list[float] = [None, None], peak_width: list[float] = [None, None] @@ -574,7 +582,11 @@ def extract_max_conductance_points(x_data: np.array, # --- Adjust layout --- plt.subplots_adjust(hspace=0.40) - plt.show() + + filepath = os.path.join(filepath, filename) + fig.savefig(filepath, dpi = 'figure', bbox_inches='tight') + + #plt.show() return best_sens_pts, (x_top, G_top) @@ -607,7 +619,7 @@ def extract_working_point(lb_data: np.array, rb_data = np.array(rb_data) current_data = np.array(current_data) barrier_pinch_offs = np.array(barrier_pinch_offs) - device_type = 'hole' + device_type = 'electron' # 2. Establish uniform coordinate grids # (Assumes original data represents a regular mesh grid) @@ -2206,7 +2218,8 @@ def extract_max_conductance_pair(x_data: np.array, peak_width: list[float] = [None, None] ): - """Analyze current data to identify the largest conductance peak and it's pair feature on the same peak. + """ + Analyze current data to identify the largest conductance peak and it's pair feature on the same peak. This function plots the current and its derivative, then highlights the most extreme conductance peak along with the pair that's on the same peak. @@ -2263,7 +2276,7 @@ def extract_max_conductance_pair(x_data: np.array, filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') - + ax1.scatter(x_max, I_max, facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Max I') ax1.scatter(x_pair, I_pair, facecolors='none', edgecolors="#01FF05", s=100, linewidths=2, zorder=5, label='Max I') ax1.set_ylabel('I (nA)', fontsize=35) @@ -2323,25 +2336,28 @@ def extract_max_conductance_pair(x_data: np.array, ax2.set_yticks([np.round(dIdV.min(), 1), 0, np.round(dIdV.max(), 1)]) ax2.set_yticklabels([str(np.round(dIdV.min(), 1)), '0', str(np.round(dIdV.max(), 1))], fontsize=25) - ax1.legend(handles=legend_marker, loc='upper left', fontsize=16, frameon=False) + ax1.legend(handles=[legend_marker], + loc='upper left', + fontsize=16, + frameon=False) # --- Adjust layout --- plt.subplots_adjust(hspace=0.40) filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') - + plt.close(fig) return conductance_pair def hough_transform(x_data: np.array, - y_data: np.array, - current_data: np.array, - filepath: str, - filename: str, - transform_trim: list = [0, -1] - ): + y_data: np.array, + current_data: np.array, + filepath: str, + filename: str, + transform_trim: list = [0, -1] + ): """ Analyze voltage data to find the slope of the line seen by cross-talk measurements using the hough transform. Can be used as regualr hough transform as well. @@ -2379,16 +2395,16 @@ def hough_transform(x_data: np.array, fig, ax = plt.subplots(figsize=(8,6)) - ax.pcolormesh( + mesh = ax.pcolormesh( unique_x, unique_y, Z_matrix, shading='auto', cmap='viridis' ) - ax.colorbar(label = "I (nA)") - ax.xlabel("Gate X Voltage (V)") - ax.ylabel("Gate Y Voltage (V)") + fig.colorbar(mesh, ax=ax, label="I (nA)") + ax.set_xlabel("Gate X Voltage (V)") + ax.set_ylabel("Gate Y Voltage (V)") filepath_raw_data = os.path.join(filepath, "raw_data_" + filename) fig.savefig(filepath_raw_data, dpi = 'figure', bbox_inches='tight') @@ -2414,7 +2430,7 @@ def hough_transform(x_data: np.array, # idx = np.where(z_der == der_min)[0][0] # x_vals.append(float(X_matrix[j, idx])) # y_vals.append(float(Y_matrix[j, idx])) - + if len(x_vals) != len(y_vals): raise ValueError("lengths of x and y values don't match") else: @@ -2427,7 +2443,7 @@ def hough_transform(x_data: np.array, ax.plot(x_vals, np.polyval([slope, intercept], x_vals), color='r', linestyle='-') # Set the limits and show - ax.ylim(min(y_vals), max(y_vals)) + ax.set_ylim(min(y_vals), max(y_vals)) filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') diff --git a/src/gui.py b/src/gui.py index af9a5c0..6a573cd 100644 --- a/src/gui.py +++ b/src/gui.py @@ -188,6 +188,11 @@ def root_page(self): on_click = self.run_bootstrapping ) + ui.button( + 'Run Global Charge Tuning', + on_click = self.run_global_charge_tuning + ) + self.debug_status = ui.label('Idle') with splitter1.after: @@ -371,6 +376,17 @@ def run_bootstrapping(self): wait = False ) + def run_global_charge_tuning(self): + + self.debug_status.set_text("Running Global Charge Tuning...") + self.logger.info("Global Charge Tuning Jobs queued") + + future = self.autotuning_handler.run_global_charge_tuning(device_config = r'C:\Users\BaughLaflamme\Documents\GitHub\QuantumDotControl\configs\Intel_Config.yaml', + instrument_handler = self.instrument_handler, + experiment_handler = self.experiment_handler, + wait = False + ) + def header(self): """ @@ -381,7 +397,7 @@ def header(self): """ with ui.header().classes(replace='row items-center') as header: - ui.label('Welcome to the Quantum Spin Qubit Device Autotuner!!!') + ui.label('Welcome to the QAT!!!') def footer(self): @@ -473,7 +489,7 @@ def update_liveplot(self): self.lines[j].set_ydata(data) self.lines[j].set_xdata(times_offset) self.ax.set_xlim(min(times_offset), max(times_offset)) - self.ax.set_ylim(-0.5, 2.0) + self.ax.set_ylim(-0.1, 1.4) self.ax.legend(self.lines, keys, ) self.liveplot.update()