diff --git a/.vscode/launch.json b/.vscode/launch.json index 6b76b4f..74857e9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,11 +5,14 @@ "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, + "python": "C:/Users/BaughLaflamme/anaconda3/envs/autotunning/python.exe" } ] } \ No newline at end of file diff --git a/configs/Intel_Config.yaml b/configs/Intel_Config.yaml index 0253152..e04baac 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: 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: spi_rack.module1.dac9.voltage + S3: label: Right Sensor Ohmic - type: Ohmic - channel: 2 - step: 0.01 - unit: V + type: Sensor Ohmic + channel: spi_rack.module1.dac4.voltage SG1: label: Horizontal Screening Gate - type: Screening - channel: 3 - step: 0.01 - unit: V + type: Central Screening + channel: spi_rack.module2.dac7.voltage CG0a: label: Dot Screening Gate - type: Screening - channel: 4 - step: 0.01 - unit: V + type: Dot Screening + channel: spi_rack.module2.dac6.voltage CG0b: label: Sensor Screening Gate - type: Screening - channel: 5 - step: 0.01 - unit: V + type: Sensor Screening + channel: spi_rack.module1.dac3.voltage AC0: label: Left Dot Accumulation Gate - type: Accumulation - channel: 6 - step: 0.01 - unit: V + type: Dot Accumulation + channel: spi_rack.module2.dac4.voltage AC1: label: Right Dot Accumulation Gate - type: Accumulation - channel: 7 - step: 0.01 - unit: V + type: Dot Accumulation + channel: spi_rack.module1.dac1.voltage 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: spi_rack.module1.dac2.voltage + F1 + AC3: + label: Left Sensor Accumulation Gate and Flanking Gates + type: Sensor Accumulation + channel: spi_rack.module2.dac3.voltage B0: label: Left Barrier Gate - type: Barrier - channel: 11 - step: 0.01 - unit: V + type: Dot Barrier + channel: spi_rack.module2.dac9.voltage B1: label: Dot 1-2 Barrier Gate - type: Barrier - channel: 12 - step: 0.01 - unit: V + type: Dot Barrier + channel: spi_rack.module2.dac5.voltage B2: label: Dot 2-3 Barrier Gate - type: Barrier - channel: 13 - step: 0.01 - unit: V + type: Dot Barrier + channel: spi_rack.module1.dac5.voltage B3: label: Right Barrier Gate - type: Barrier - channel: 14 - step: 0.01 - unit: V + type: Dot Barrier + channel: spi_rack.module2.dac10.voltage B20: label: Left Sensor Barrier Gate - type: Barrier - channel: 15 - step: 0.01 - unit: V + type: Sensor Barrier + channel: spi_rack.module1.dac8.voltage B21: label: Right Sensor Barrier Gate - type: Barrier - channel: 16 - step: 0.01 - unit: V + type: Sensor Barrier + channel: spi_rack.module1.dac7.voltage P0: label: Left Dot Plunger - type: Plunger - channel: 17 - step: 0.01 - unit: V + type: Dot Plunger + channel: spi_rack.module2.dac1.voltage P1: label: Middle Dot Plunger - type: Plunger - channel: 18 - step: 0.01 - unit: V + type: Dot Plunger + channel: spi_rack.module2.dac0.voltage P2: label: Right Dot Plunger - type: Plunger - channel: 19 - step: 0.01 - unit: V + type: Dot Plunger + channel: spi_rack.module1.dac0.voltage P20: label: Sensor Plunger - type: Plunger - channel: 20 - step: 0.01 - unit: V + type: Sensor Plunger + channel: spi_rack.module2.dac2.voltage 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_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + voltage_dividers: + 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 new file mode 100644 index 0000000..a30bfad --- /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: 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: spi_rack.module1.dac10.voltage + SG1: + label: Horizontal Screening Gate + type: Central Screening + channel: spi_rack.module1.dac11.voltage + CG0b: + label: Sensor Screening Gate + type: Sensor Screening + channel: spi_rack.module1.dac12.voltage + AC2: + label: Right Sensor Accumulation Gate + type: Sensor Accumulation + channel: spi_rack.module1.dac13.voltage + F1 + AC3: + label: Left Sensor Accumulation Gate and Flanking Gates + type: Sensor Accumulation + channel: spi_rack.module1.dac14.voltage + B20: + label: Left Sensor Barrier Gate + type: Sensor Barrier + channel: spi_rack.module2.dac14.voltage + B21: + label: Right Sensor Barrier Gate + type: Sensor Barrier + channel: spi_rack.module2.dac13.voltage + P20: + label: Sensor Plunger + type: Sensor Plunger + channel: spi_rack.module1.dac15.voltage +setup: + triple_dot_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + SET_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + voltage_dividers: + voltage_divider_triple_dot: 1.0e-2 + voltage_divider_SET: 1.0e-2 + 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..2227677 --- /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: 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: spi_rack.module1.dac6.voltage + SG1: + label: Horizontal Screening Gate + type: Central Screening + channel: spi_rack.module1.dac10.voltage + CG0a: + label: Dot Screening Gate + type: Dot Screening + channel: spi_rack.module1.dac11.voltage + AC0: + label: Left Dot Accumulation Gate + type: Dot Accumulation + channel: spi_rack.module1.dac12.voltage + AC1: + label: Right Dot Accumulation Gate + type: Dot Accumulation + channel: spi_rack.module1.dac13.voltage + B0: + label: Left Barrier Gate + type: Dot Barrier + channel: spi_rack.module2.dac13.voltage + B1: + label: Dot 1-2 Barrier Gate + type: Dot Barrier + channel: spi_rack.module2.dac14.voltage + B2: + label: Dot 2-3 Barrier Gate + type: Dot Barrier + channel: spi_rack.module2.dac11.voltage + B3: + label: Right Barrier Gate + type: Dot Barrier + channel: spi_rack.module2.dac12.voltage + P0: + label: Left Dot Plunger + type: Dot Plunger + channel: spi_rack.module1.dac14.voltage + P1: + label: Middle Dot Plunger + type: Dot Plunger + channel: spi_rack.module1.dac15.voltage + P2: + label: Right Dot Plunger + type: Dot Plunger + channel: spi_rack.module2.dac15.voltage +setup: + triple_dot_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + SET_preamp: + preamp_bias: 0.0 + preamp_sensitivity: 1.0e-8 + voltage_dividers: + voltage_divider_triple_dot: 1.0e-2 + voltage_divider_SET: 1.0e-2 + diff --git a/configs/dummy_station.yaml b/configs/dummy_station.yaml new file mode 100644 index 0000000..92d8037 --- /dev/null +++ b/configs/dummy_station.yaml @@ -0,0 +1,5 @@ +instruments: + DummyInst: + type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument + DummyInst2: + type: gui.RandomDummy #qcodes.instrument_drivers.mock_instruments.DummyInstrument \ No newline at end of file diff --git a/configs/test_station.yaml b/configs/test_station.yaml new file mode 100644 index 0000000..b7da6f6 --- /dev/null +++ b/configs/test_station.yaml @@ -0,0 +1,12 @@ +instruments: + 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/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_handler.py b/src/autotuning_handler.py new file mode 100644 index 0000000..a375dda --- /dev/null +++ b/src/autotuning_handler.py @@ -0,0 +1,256 @@ +''' +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, + device_config, + instrument_handler, + experiment_handler, + wait: bool = True, + timeout: float = 6000): + + def sweep_fn(abort_event): + 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=(), + 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 5b7c40b..2884a45 100644 --- a/src/autotuning_protocol.py +++ b/src/autotuning_protocol.py @@ -21,58 +21,3208 @@ 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 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_pair, extract_max_conductance_points, extract_lever_arms, hough_transform + import sys -from nicegui import ui -import threading +from nicegui import ui +from tunerlog import TunerLog + +logger = TunerLog('Autotuning Protocol') + +class Protocol: + + 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. + + + ''' + + 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 = {} + + for i in self.device_gates: + + 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 + + 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 = 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 = 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_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): + + super().__init__(device_config = device_config, instrument_handler = instrument_handler, experiment_handler = experiment_handler) + + self.noise_floor = None + + 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(f"{pinch_off_voltages}") + logger.info(f"{saturation_voltages}") + + working_point, dot_barrier_set_points = self.barrier_barrier_sweep(lower_voltages = pinch_off_voltages, + upper_voltages = saturation_voltages, + num_points = 200) + + self.sensor_barrier_voltages = list(working_point) + + self.plunger_starting_voltages = [sum(working_point) / len(working_point)] + + 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 + ) + + 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) + 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): + + # 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.split('.', 1) + + dacs_and_vals[i] = instr_handler.get_parameter( + instr, + param, + wait=True + ) + + # Now, we create the sweep parameters + + targets = [] + + for gate, dac_and_val in dacs_and_vals.items(): + for dac, starting_val in dac_and_val.items(): + + param = SweepParam( + parameter = "spi_rack." + dac, + start = starting_val, + end = 0.0 + ) + + targets.append(param) + + sweep_layer = SweepLayer( + targets = 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("Grounding Device...") + + future = exp_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = instr_handler) + + logger.info("Device Grounded!") + + 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. + + 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'] == "Dot Ohmic" or self.device_gates[i]['type'] == "Sensor Ohmic": + + p = self.device_gates[i]['channel'] + + ohmic_voltage = ohmic_bias / self.voltage_divider_SET + + sparam = SweepParam( + parameter = p, + start = 0.0, + end = ohmic_voltage + ) + + ohmic_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = ohmic_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 Ohmic Bias...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + 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'] + + sparam = SweepParam( + parameter = p, + start = 0.0, + end = screening_voltage + ) + + screening_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = screening_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 Screening Gate Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + 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'] + + sparam = SweepParam( + parameter = p, + start = 0, + end = gate_voltage + ) + + gate_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = gate_targets, + 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("Device Turn-On Starting...") + + 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...") + + 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) + + """ + 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 = [] + + for i, item in enumerate(self.means): + + if abs(10 * item) < means[i]: + turn_on_check.append(True) + else: + turn_on_check.append(False) + + if all(item is True for item in turn_on_check): + + logger.info("Turn-Ons Confirmed Succussfully! Determining Turn-On Voltages...") + + # Get the data from the CSV + + filepath = os.path.join(self.directory, filename) + + df = pd.read_csv(filepath, delimiter = ",", header = None, skiprows = 1) + + # 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 + + turn_on_sweep = df.iloc[:,0].to_numpy() + + triple_dot_data = df.iloc[:,-2].to_numpy() * self.triple_dot_preamp_sensitivity * 1e9 + + SET_data = df.iloc[:,-1].to_numpy() * self.SET_preamp_sensitivity * 1e9 + + current_means = [] + + for mean in self.means: + + mean *= self.SET_preamp_sensitivity * 1e9 + + current_means.append(mean) + + current_data = [triple_dot_data, SET_data] + + turn_on_voltages = [] + + turn_on_filenames = ['Triple_Dot_Turn_On' + time + '.png', 'SET_Turn_On' + time + '.png'] + + for i, mean in enumerate(self.means): + + 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) + + return turn_on_voltages + + else: + + 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() + + return None + + def pinch_off(self, gate_voltage, final_voltages, num_points): + + """ + 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. + + """ + + excluded_types = ["Dot Ohmic", "Sensor Ohmic", "Dot Screening", "Sensor Screening", "Central Screening", "Dot Barrier", "Sensor Barrier"] + + triple_dot_turn_on, SET_turn_on = final_voltages + + pinch_off_voltages= [] + + saturation_voltages = [] + + for i in self.gates_to_dacs: + + if self.device_gates[i]['type'] not in excluded_types: + + if self.device_gates[i]['type'].startswith('Dot'): + + 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_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]) + + else: + + 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}") + + # 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 + + # For this specific device, the accumulation gates do not Pinch-Off, so we will be exluding them. For now, we will exclude this step. + + # Now, we set the voltages on these gates to the the saturation voltages + + saturation_voltages = [1.25, 1.25, 1.25, 1.25, 1.2, 1.2, 1.2, 1.15] + + pinch_off_dacs_and_vals = {} + + 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.split('.', 1) + + pinch_off_dacs_and_vals[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + pinch_off_targets = [] + + endpoint_iter = iter(saturation_voltages) + + for gate, dac_and_val in pinch_off_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}") + + if end_val == None: + continue + + sparam = SweepParam( + parameter = p, + start = starting_val, + end = end_val + ) + + logger.info(f"{sparam}") + + pinch_off_targets.append(sparam) + + sweep_layer = SweepLayer( + targets = pinch_off_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 Saturation Voltages...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Saturation Voltages Set!") + + 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 + + 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'] in included_types: + + if self.device_gates[i]['type'].startswith('Dot'): + + 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_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]) + + logger.info("Pinch-Offs Complete!") + + return barrier_pinch_off_voltages, barrier_saturation_voltages + + def pinch_off_individual(self, gate_voltage, final_voltage, gate_name, gate_type, channel, num_points): + + """ + Sweeps a single gate to determine pinch-off. + + Returns a (pinch_off_voltage, saturation_voltage) tuple on success, or (None, None) on failure. + """ + + 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" + + 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...") + + 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. + + """ + + noise_floor_idx = None + + 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.") + + if pinched: + + 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) + + pinch_off_sweep = df.iloc[:, 0] + data = [df.iloc[:, -2] * self.triple_dot_preamp_sensitivity * 1e9, df.iloc[:, -1] * self.SET_preamp_sensitivity * 1e9] + + 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}") + + return pinch_off_window + + else: + + logger.info(f"{gate_name} did not pinch off at {final_voltage} V. Pinch-Off Failed. Returning None...") + + return (None, None) + + def SET_current_check(self, minimum_current, maximum_current): + + # 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() + + names = list(self.instrument_handler.read_buffer( + ['agilent_left.volt', 'agilent_right.volt'], + ).keys()) + + current_means = [] + + for i in names: + + mean_name = i + "_mean" + mean = current_level[mean_name] * self.SET_preamp_sensitivity * 1e9 + + 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: + + logger.info("For Loop!") + + if self.device_gates[i]['type'] in included_types: + + p = self.device_gates[i]['channel'] + + instr, param = p.split('.', 1) + + logger.info(f"{p}") + + accumulation_voltages[i] = self.instrument_handler.get_parameter( + instr, + param, + wait=True + ) + + SET_current_level = current_means[1] + + logger.info(f"Current Level {SET_current_level}. Checking Current Level...") + + while SET_current_level > maximum_current or SET_current_level < minimum_current: + + if SET_current_level > maximum_current: + + logger.info("Current too high! Reducing Accumulation Gate Voltages by 1 mV...") + + # We reduce 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(10) + + 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(10) + + 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 = 3, maximum_current = 5) + + # 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" + + 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) + + 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[:, 1] + rb_data = df.iloc[:, 0] + + current_data = df.iloc[:, -1] + + filename = filename.removesuffix('.csv') + ".png" + + 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}") + + return best_point, best_points + + 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 = 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 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.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 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}") + + sensor_plunger_idx = 0 + + for i in self.gates_to_dacs: + + 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 + + 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...") + + 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, (best_point, best_conductance) = extract_max_conductance_points(x_data = plunger_data, + y_data = current_data, + filepath = self.directory, + filename = filename + ) + + 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): + + # First, we get 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 + ) + + logger.info(f"{sd_dacs_and_vals}") + + # We also get our plunger voltages to set them to their lower voltages + + 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 + ) + + logger.info(f"{plunger_dacs_and_vals}") + + 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) + + logger.info(f"{sd_targets}") + + 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) + + logger.info(f"{plunger_targets}") + + 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 Plungers to Initial Points...") + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) + + logger.info("Sensor Plungers Set!") + + # Now, we make the Coulomb Diamond Sweeps + + sensor_plunger_targets = [] + + sensor_ohmic_targets = [] + + sensor_plunger_idx = 0 + + 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 = lower_plunger_voltages[sensor_plunger_idx], + end = upper_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'] + + name = self.device_gates[i]["label"] + + sensor_ohmic_names.append(name) + + 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 = [item], + 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, + filename = filename + ) + + logger.info("Coulomb Diamond Sweep Complete! Finding Diamonds...") + +class GlobalChargeTuning(Bootstrapping): + + def __init__(self, device_config, instrument_handler, experiment_handler): + + Protocol.__init__(self, + device_config=device_config, + instrument_handler=instrument_handler, + experiment_handler=experiment_handler + ) + + self.plunger_starting_voltages = [1.1674690763420748] + + self.plunger_ending_voltages = [1.5] + + self.dot_plunger_lower_voltages = [1.2587939698492463 - 0.02, 1.2814070351758793 - 0.02, 1.2587939698492463 - 0.02] + + self.dot_plunger_idle_voltages = [1.2587939698492463, 1.2814070351758793, 1.2587939698492463] + + self.dot_plunger_upper_voltages = [1.2587939698492463 + 0.02, 1.2814070351758793 + 0.02, 1.2587939698492463 + 0.02] + + 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}") + + 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 + ) + + logger.info(f"{confirmation}") + + self.dot_plunger_lower_voltages = [1.2587939698492463 - 0.12, 1.2814070351758793 - 0.12, 1.2587939698492463 - 0.12] + + 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 = {} + + 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.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 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}") + + sensor_plunger_idx = 0 + + for i in self.gates_to_dacs: + + 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 + + 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...") + + 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_pair(x_data = plunger_data, + y_data = current_data, + filepath = self.directory, + filename = filename + ) + + 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: + + 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(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(): + + 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 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(): + + 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 2D scans, in which the Sensor plunger is swept and the dot plungers are stepped and the return 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_targets.append(sparam) + + 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 = 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', + '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...") + + 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 + ) + + crosstalk_vals.append(slope) + + logger.info("Returning plungers to the original points...") + + return_layer = SweepLayer(targets = [dot_return_targets[j], sensor_return_targets[i]], + 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([return_layer], measure) + + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler + ) + + return crosstalk_vals + + 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': + + 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(lower_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(): -class Bootstrapping: + p = "spi_rack." + dac - def ground_device(): - pass + sensor_plunger_val = next(endpoint_iter_sensor) - def turn_on(): - pass + sparam = SweepParam( + parameter = p, + start = starting_val, + end = sensor_plunger_val + ) - def pinch_off(): - pass + sensor_plunger_targets.append(sparam) - def barrier_barrier_sweep(): - pass + sweep_layer = SweepLayer( + targets = sensor_plunger_targets, + num_points = num_points, + measurement_time = 0.1 + ) - def set_plunger_sweep(): - pass + measure = lambda ih, sp: ( + ih.read_buffer([ + 'agilent_left.volt', + 'agilent_right.volt' + ]), + ['agilent_left.volt', 'agilent_right.volt'] + ) - def coulomb_diamonds(): - pass + sweep = Sweep([sweep_layer], measure) - def tune_lead_dot_tunneling(): - pass + logger.info("Setting Charge Sensor to Newly Calibrated Point...") -class CoarseTuning: + future = self.experiment_handler.set_voltage_configuration(sweep = sweep, + instrument_handler = self.instrument_handler) - def plunger_plunger_sweep(): - pass + logger.info("Charge Sensor Calibrated!") + + # Now, we determine the voltage ranges over which our sensor plungers wil be counterswept + + plunger_crosstalk_vals = [np.float64(-3.0840343159529215 - 0.5), np.float64(-3.948856914488276 - 0.5), np.float64(-5.174829249744017 - 0.5)] + + 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}") + + # 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': + + p = self.device_gates[i]['channel'] + + 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] + ) + + logger.info(f"sparam: {sparam}") + + sensorparam = SweepParam( + parameter = sensor_parameters[0], + start = sensing_point, + end = final_sensor_voltages[dot_plunger_idx] + ) + + 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, + 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 + + barrier_setpoints = [] -class VirtualGating: + 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 barrier_setpoints + + def plunger_plunger_sweep(self, lower_plunger_voltages, idle_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': + + 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): + + 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 QubitTuning(Protocol): + + def __init__(self, device_config): + super().__init__(device_config = device_config) def rabi_oscilations(): pass diff --git a/src/buffered_readout.py b/src/buffered_readout.py deleted file mode 100644 index 258cde3..0000000 --- a/src/buffered_readout.py +++ /dev/null @@ -1,141 +0,0 @@ -import threading -import time -import numpy as np -from typing import List, Tuple -import random - -__BufferExists__ = False -__Instance__ = None - - -def create_buffer_instance(): - global __Instance__ - if __Instance__ is None: - __Instance__ = buffered_readout() - - return __Instance__ - - -class buffered_readout: - 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!!" - - __BufferExists__ = True - - self.time_func = time.time - - 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.buffer = [(float(0), float(-1.0))] * self.BUFFER_SIZE - self.buffer_index = 0 - 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() - - 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 __open_instruments__(self): - - self.__assert_correct_thread__() - - return - def __thread_loop__(self): - - 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__() - - print(f"Stopping the readout buffer thread...") - return - - 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 - - def read_buffer(self, t_avg : float = 0.0, t_start : float = 0.0) -> float: - ''' - - ''' - if t_start <= 0.0: - t_start = time.time() - - # acquire a lock on the buffer for Readout, and then copy it - with self.buffer_lock: - buffer_copy = self.buffer.copy() - - # Sort according to the time stamp - buffer_copy.sort(key = lambda e: e[1]) - - 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] - - if timestamp <= t_start: - values.append(buffer_copy[i][0]) - return float(np.average(values)) - def get_buffer(self) -> Tuple | None: - ''' - Try to copy the buffer without blocking. If it fails to acquire the lock, - it will return None. - ''' - - if self.buffer_lock.acquire(blocking = False): - try: - copy = self.buffer.copy() - 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) - - def run(self): - if not self.running: - self.thread.start() - self.running = True - def join(self): - self.shutdown_event.set() - self.thread.join() diff --git a/src/data_analysis.py b/src/data_analysis.py index 563f343..c8baec7 100644 --- a/src/data_analysis.py +++ b/src/data_analysis.py @@ -1,459 +1,2453 @@ -# 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 -import pandas as pd - +# Third-party libraries import numpy as np +import numpy.typing as npt +import pandas as pd +import cv2 +import scipy.signal as signal +from scipy.interpolate import make_smoothing_spline -import scipy as sp -from scipy.ndimage import convolve - -import matplotlib.pyplot as plt 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, Rectangle -from typing import List, Dict, Callable +from IPython.display import display -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 +from scipy.optimize import curve_fit +from scipy.special import expit +from scipy.ndimage import convolve, map_coordinates, gaussian_filter1d import skimage -from skimage.transform import probabilistic_hough_line +from skimage import filters, transform from skimage.feature import canny -from skimage.filters import threshold_otsu +from skimage.filters import threshold_otsu, sato from skimage.morphology import diamond, rectangle # noqa +from skimage.transform import probabilistic_hough_line -import logging +import yaml from colorlog import ColoredFormatter -import sys + +import qcodes as qc +from qcodes.dataset import AbstractSweep, Measurement +from qcodes.dataset.dond.do_nd_utils import ActionsT +from qcodes.parameters import ParameterBase from nicegui import ui -import threading +from tunerlog import TunerLog + +logger = TunerLog('Data Analysis') + +def logarithmic(x, a, b, x0, y0): + + """ + 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 + +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): + """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): + """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, + 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 + perr: Error of parameter estimates (square root of covariances) + """ + + if p0 is None: + 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, maxfev=10000) + perr = np.sqrt(np.diag(pcov)) + + params = list(inspect.signature(function).parameters.keys())[1:] + + if print_results: + for name, val, err in zip(params, popt, perr): + print(f"{name} = {val:.3f} ± {err:.3f}") + return params, popt, perr -class DataAnalysis: +def extract_turn_on_voltage(x_data: np.array, + y_data: np.array, + noisefloor: float, + filepath: str, + filename: str, + plot_results: bool = True): - def __init__(self, - logger, - tuner_config) -> None: + """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) + + if y1[-1] < 0: + y1 = -y1 + + # --- Finding Turn-On Voltage --- + turnon_voltage = 0 + turnon_current = 0 + + threshold = noisefloor * 10 + + for val in y1: + + if val > abs(threshold): + idx_turnon = np.where(y1 == val)[0][0] # get the index of the turn-on point + + 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: + + 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') + + # --- Labels and formatting --- + + 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) + + 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) + + 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 --- + #print(f" Turn-on Voltage: {turnon_voltage:.3f} V") + + 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, + 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 --- + + # 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 + 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) # mask to scan over + x_scan = x1[scan_indices] + y_scan = y1[scan_indices] + + # 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_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, + # 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 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): + 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 + else: + idx_pinch_off = int(scan_indices[pinch_off_pos]) + + # 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)))) + 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]) + + # Finds the voltage and current + pinch_off_voltage = x1[idx_pinch_off] + pinch_off_current = y1_norm[idx_pinch_off] + + # --- Fit sigmoids (Gompertz function) --- + + 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, 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_saturation_voltage + sat_current = y1_norm[np.argmax(np.isclose(x1, sat_voltage, atol=1e-3, rtol=1e-3))] + sat_label = 'Saturation Point' + + 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_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') + + 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 --- + + # Define arrow y-positions (swap positions) + + y_arrow1 = ax.get_ylim()[1] + 0.05 # Device 1 arrow + + # Device 1 arrow (now above) - self.logger = logger + 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) - self.tuner_info = yaml.safe_load(Path(tuner_config).read_text()) + # --- Characteristic vertical lines extending to the data points --- - 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 + y_pinch1 = y1_norm[np.argmax(np.isclose(x1, pinch_off_voltage, atol=1e-3))] - def exponential(self, x, a, b, x0, y0): - return a * np.exp(b * (x-x0)) + y0 + 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, sat_current) + ]: + 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) - def sigmoid(self, x, a, b, x0, y0): - return a/(1+np.exp(b * (x-x0))) + y0 + # --- Labels and formatting --- + + 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() + 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]:.2f}', '', f'{xticks_span[2]:.2f}', '', f'{xticks_span[-1]:.2f}'], fontsize=25) + + yticks_span = np.linspace(y1_norm.min(), y1_norm.max(), 5) + + ax.set_yticks(yticks_span) + ax.set_yticklabels([f'{y1.min():.2f}', '', '', '', f'{y1.max():.3f}'], fontsize=25) + + # Extend y-limits slightly to make space for arrows + + 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() + + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) + fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') + + plt.close(fig) + + voltage_window = (pinch_off_voltage, sat_voltage) + + return voltage_window + +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] + ): + """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) + + # Now, we calculate the derivative and replot + + dIdV = np.gradient(y1, x1) + + 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)] + + + # --- 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, _ = 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])) + + # Extract the corresponding data points + 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])] + + 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 = [(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)) + + # --- Top panel: Current --- + ax1.plot(x1, y1, color='#2c5aa0', linewidth=1) + 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), max(x1)) + ax1.tick_params(labelbottom=True) + + # --- Bottom panel: Conductance --- + 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 --- + 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 --- + + # 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, legend_marker_2], loc='upper left', fontsize=16, frameon=False) + + # --- Adjust layout --- + plt.subplots_adjust(hspace=0.40) + + filepath = os.path.join(filepath, filename) + fig.savefig(filepath, dpi = 'figure', bbox_inches='tight') + + #plt.show() + + return best_sens_pts, (x_top, G_top) + +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], + filepath: str, + filename: str, + minAngleDeg: float = -60, + maxAngleDeg: float = -30, + minLineLength: int = 50, + maxLineGap: int = 200, + debug: bool = False, + plot_results: bool = True): - def linear(self, x, m, b): - return m * x + b + """ + 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) + barrier_pinch_offs = np.array(barrier_pinch_offs) + device_type = 'electron' + + # 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 + + if np.average(lb_data) > 0 and np.average(rb_data) > 0: + current_data = np.flip(current_data, axis=None) + device_type = 'electron' + + # 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: + # 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 + + # 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) + + logger.info("calculation starting...") + + # ---------- 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.18 + + # 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) + + # 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) + + # 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 + ridge_masked[:y_idx_mid, :x_idx_mid] = ridge_filtered[:y_idx_mid, :x_idx_mid] + else: # hole + # Hole: analyze top-right + 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)) + + logger.info("hough lines drawing...") + + lines = transform.probabilistic_hough_line( + ridge_masked, + threshold=hough_threshold, + line_length=hough_length, + line_gap=hough_gap + ) + + # if not lines: + # return [] + + # Now, we filter for lines within a certain angle range + + 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)) + 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] + + logger.info("filtering...") + + if not filtered_lines: + # 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] + + 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 + + # enlarge region by adding 0.1 V to pinch-off values + 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 = [] + + perp_length_pixels = max(40, int(min(nx, ny) * 0.5)) + 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 + + logger.info("finding traces...") + + 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" + ) - params = list(inspect.signature(function).parameters.keys())[1:] + # 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(conductance - np.median(conductance))) + prominence_thresh = 4.0 * noise_sigma + peaks, _ = signal.find_peaks(conductance, + 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) + + # 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] + 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 + ) + ) - for name, val, err in zip(params, popt, perr): - print(f"{name} = {val:.3f} ± {err:.3f}") + 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 ---------- - return params, popt, pcov + # 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 + + N_FINAL = 4 + top_candidates = perp_candidates[:N_FINAL] + + selected_trace_ids = {c[5] for c in top_candidates} + + selected_peaks_by_trace = { + c[5]: (c[1], c[2]) for c in top_candidates + } + + 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 + ] + + 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 + # 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 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) + 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))) + + dist_to_pinch_off_corner[tuple(cand_point)] = np.linalg.norm(cand_point - barrier_pinch_offs) - def gradient(self): - pass + 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 + ] + + # 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) + + 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 + + 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: + best_count = -1 + best_block = None + for b in blocks: + 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] + + 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]) - def extract_bias_point(self, - data: pd.DataFrame, - plot_process: bool, - axes: plt.Axes): + # ---------- Final Plotting ---------- + + logger.info("Plotting Results...") + + 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' + ) + + 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()) + + # 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([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", + 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 + ) + + # Axis labels + 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) + + # 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_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() + + cbar.ax.xaxis.set_minor_locator(AutoMinorLocator(5)) + + # Style minor ticks + cbar.ax.tick_params( + which="minor", + direction="in", + length=4, + width=1.0 + ) - if plot_process: - - # Plot the bounding box - - 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) + # Block Boundary and shaded region based on device type + + if device_type == 'electron': + # Bottom-left quadrant boundary + + # Top edge of the bottom-left quadrant + ax.plot( + [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 edge of the bottom-left quadrant + ax.plot( + [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 + ) + + # 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) - 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])} + else: # hole + # Top-right quadrant boundary + + # Bottom edge of the top-right quadrant + ax.plot( + [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 edge of the top-right quadrant + ax.plot( + [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 + ) + + # 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 ) - data.iloc[:,-1] = data.iloc[:,-1].subtract(0).mul(1e-7) # sensitivity + 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) + + # Uncomment below to see the detected Hough lines + # 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 = [] + best_shifted_point = None + green_circle = False + + 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) + + 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)) + + 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) + except Exception: + pass + + ax.set_box_aspect(0.775) + + filepath_analyzed = os.path.join(filepath, "analyzed_" + filename) + fig.savefig(filepath_analyzed, dpi = 'figure', bbox_inches='tight') + + logger.info("Figure saved!") + + plt.close(fig) + + # 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']}" + ) + + + + # 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") + + # 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 + + 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(rf'V$_{{{gates[0]}}}$ (V)', fontsize=45) + plt.ylabel(rf'V$_{{{gates[1]}}}$ (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() + + # Here is the plot of the band-passed G_uint, i.e. after being thresholded - 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 + 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 + # Here is a plot of the ridges - maxima = sp.signal.argrelextrema(G_filtered, np.greater)[0] - maxima_indices = maxima[G_filtered[maxima] >= threshold] + 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() - if len(maxima_indices) != 0: - results = dict(zip(V_data.iloc[maxima_indices], G_filtered[maxima_indices])) + # Here is a plot of the normalized ridges - results_sorted = dict(sorted(results.items(), key=lambda item: item[1])) + 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() - if plot_process: + # Here is a plot of the ridges filtered for strength - # Create figure and axes - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), sharex=True) + 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() - # 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) + # ---------- Hough Line Plotting ---------- - 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: + # Now, we'll plot a set of lines from the Hough Transform at each preprocessing stage - 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]) - df_pivoted = data.pivot_table(values=Z_name, index=Y_name, columns=X_name).fillna(0) - Zdata = df_pivoted.to_numpy() + # 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' + ) - # Calculate conductance where G = dI / dVp - G = np.gradient(Zdata)[1] + 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 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() + 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' + ) + + 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() + + # 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' + ) - 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_uint, + 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 G Normalized") + plt.show() + + # Now, we detect lines from the G_log normalized after thresholding + + 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' + ) + + 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)) + ) + + 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() - addition_voltages = [] - charging_voltages = [] - results = {} + # Now, we detect lines from the ridges + 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' + ) - for i, contour in enumerate(contours): - if len(contour) < 350: - continue + lines = transform.probabilistic_hough_line( + ridge, + 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 Ridges") + plt.show() + + # Now, we detect lines from 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' + ) + + 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)) + ) + + 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() + + logger.info("Returning...") + + if DotTuning == 'Triple Dot': + return best_shifted_point, shifted_points, perp_traces_for_plot + elif DotTuning == 'SET': + return best_shifted_point, perp_bias_points, perp_traces_for_plot + +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) - Xmax = max(X) - Xmin = min(X) - Ymax = max(Y) - Ymin = min(Y) + # ========================================================================= + # 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 = [] - # Get centroid - centroidX, centroidY = 0.5*(Xmax + Xmin), 0.5 * (Ymax + Ymin) + 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] - dX = Xmax - Xmin - dY = Ymax - Ymin + spline = make_smoothing_spline(x_vals, z_vals, lam=1e-9) + smoothed_z_vals = spline(x_vals) - divider = 1e-3 - alpha= (Ymax * divider /2) / dX + deriv = np.gradient(smoothed_z_vals, x_vals) + neg_deriv = -deriv - e = 1.60217663e-19 # C + 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)] - eps0 = 8.8541878128e-12 # F/m - epsR = 11.7 # Silicon + print(f"Tunnel Barrier Voltage = {trace_y_values[i]:.3f} V") - 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 + 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) - results[i]= { - 'centroid': (centroidX, centroidY), - 'Vadd': Vadd, - 'Vcharge': Vc, - 'Cp': C_P, - 'CSigma': C_sigma, - 'lever arm': alpha, - 'dot size': dot_size - } + # # 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 + 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, + 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 separate + X_name, Y_name, Z_name = data.columns + Xdata, Ydata = np.unique(data[X_name]), np.unique(data[Y_name]) - 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') + df_pivoted = data.pivot_table(values=Z_name, index=Y_name, columns=X_name).fillna(0) + Zdata = df_pivoted.to_numpy() - 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') + # Calculate conductance where G = dI / dVp + G = np.gradient(Zdata)[1] - 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') + 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() + + # 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() - 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') + # Apply binary threshold to bring out diamonds better + thresh = threshold_otsu(filtered_G) + binary_image = filtered_G < thresh - 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') + 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() - 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') + # 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() - return results \ No newline at end of file + + # 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 + +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)) + + mesh = ax.pcolormesh( + unique_x, + unique_y, + Z_matrix, + shading='auto', + cmap='viridis' + ) + 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') + + # 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.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') + + plt.close(fig) + + return slope, intercept diff --git a/src/data_analysis_test.ipynb b/src/data_analysis_test.ipynb new file mode 100644 index 0000000..661055c --- /dev/null +++ b/src/data_analysis_test.ipynb @@ -0,0 +1,368 @@ +{ + "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\\\\\"\n", + "# file_path = \"N:\\\\W26-S26 Baugh Lab Coop\\\\Measurement Data 3D1S Autotuning\\\\\"" + ] + }, + { + "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", + "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", + "\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", + "\n", + "files = [\"B20PO.csv\", \"B21PO.csv\", \"B20S_2.csv\", \"B21S_2.csv\", \"P20PO.csv\"]\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", + "\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)" + ] + }, + { + "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", + "gate_types = ['Barrier', 'Barrier']\n", + "barrier_pinch_offs = []\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", + " Xdata = df[name].to_numpy()\n", + " TDdata = df[\"SET Voltage (V)\"].to_numpy()*1e-7*1e9 #nA\n", + " 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, gate_types[i])\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 = [\"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", + " 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", + "\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", + "\n", + "\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", + " 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(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)" + ] + }, + { + "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', '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[0]\n", + " Xdata = df[name].to_numpy()\n", + " name = df.columns[-2]\n", + " TDdata = df[name].to_numpy()*1e-8*1e9 #nA\n", + "\n", + " print(f\"TD {gate_types[i]} Gate 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", + "\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']\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", + "\n", + " print(f\"SET {gate_types[i]} Gate 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)" + ] + }, + { + "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 + \"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[\"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(\"B20-B21 Sweep:\")\n", + "for i in range(1):\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_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 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", + "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": { + "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 +} diff --git a/src/experiment_base.py b/src/experiment_base.py new file mode 100644 index 0000000..40364fe --- /dev/null +++ b/src/experiment_base.py @@ -0,0 +1,349 @@ +''' +File: experiment_base.py +Authors: Benjamin Van Osch (bvanosch@uwaterloo.ca), Mason Daub (mjdaub@uwaterloo.ca) + +Defines SweepLayer objects and the Sweep class to handle all the running of all sweeps used in the Autotuner. + +''' + +# Imports + +from __future__ import annotations + +import csv +import os +from datetime import datetime + +import time +import numpy as np +from dataclasses import dataclass +from tunerlog import TunerLog + +logger = TunerLog('Exp. Base') + +@dataclass +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 self.num_points <= 0: + raise ValueError("num_points must be > 0") + +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.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, filename): + + keys = [ + 'agilent_left.volt', + 'agilent_right.volt' + ] + 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) + + def _close_csv(self): + if self._csv_file is not None: + self._csv_file.close() + + 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 + ) + + finally: + print() + + 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. THIS METHOD DOES NOT RECURSE. + + 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 != 0: + raise ValueError("Setting a voltage layer should only have one layer!") + + if idx == len(self.layers): + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + return + + layer = self.layers[idx] + + values_per_param = [ + np.linspace(p.start, p.end, layer.num_points) + for p in layer.targets + ] + + for i in range(layer.num_points): + + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + + step_values = {} + + 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 + + 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) + + def run(self, instr_handler, abort_event, filename, current_setpoints = {}): + + try: + + self._open_csv(filename = filename) + + self._run_layer( + 0, + instr_handler, + abort_event, + current_setpoints=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): + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + + data, keys = self.measure(instr_handler, current_setpoints.copy()) + + self.results.append({ + "setpoints": current_setpoints.copy(), + "data": 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() + + return + + layer = self.layers[idx] + + values_per_param = [ + np.linspace(p.start, p.end, layer.num_points) + for p in layer.targets + ] + + for i in range(layer.num_points): + + if abort_event.is_set(): + raise RuntimeError("Sweep aborted") + + step_values = {} + + 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 + + t0 = time.perf_counter() + + 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) + + # Recurse + + self._run_layer( + idx + 1, + instr_handler, + abort_event, + new_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, + reset_start, + reset_end, + num_points=50 + ) + + # Save original layers + original_layers = self.layers + + try: + # Swap in reset layers + self.layers = reset_layer + + # 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:]. + """ + + reset_layer = [] + + new_targets = [] + + for layer in self.layers[idx + 1:]: + + for p in layer.targets: + param = p.parameter + + v_start = start_setpoints + v_end = end_setpoints + + 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 + ) + + new_targets.append(new_p) + + # Recreate layer + + new_layer = type(layer)( + targets=new_targets, + num_points=num_points, + measurement_time=layer.measurement_time + ) + + 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 new file mode 100644 index 0000000..7d280ae --- /dev/null +++ b/src/experiment_handler.py @@ -0,0 +1,228 @@ +''' +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 + +_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" + 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() + 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() + + 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 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(): + + 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() + +class experiment_handler: + + def __init__(self, experiment_thread): + self.experiment_thread = experiment_thread + + def do_sweep(self, + sweep, + instrument_handler, + filename, + current_setpoints = {}, + wait: bool = True, + timeout: float = 60000): + + logger.info("Sweep Start!") + + def sweep_fn(abort_event): + result = sweep.run(instrument_handler, abort_event, filename, current_setpoints) + + return result + + 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 = 60000): + + def sweep_fn(abort_event): + result = sweep.set_voltage_configuration(instrument_handler, abort_event, current_setpoints) + return result + + return self.experiment_thread.add_job( + sweep_fn, + args=(), + wait=wait, + timeout=timeout + ) \ No newline at end of file diff --git a/src/experiment_thread.py b/src/experiment_thread.py deleted file mode 100644 index 20e08a4..0000000 --- a/src/experiment_thread.py +++ /dev/null @@ -1,79 +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 nicegui import app - -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 = "experimental_thread" - self.thread = threading.Thread(target = self.__thread_loop__, name = self.THREAD_NAME) - - - def run(self): - - self.thread.start() - - def join(self): - - 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): - - self.job_queue.put((priority,(f, args))) - - def abort(self): - - self.abort_event.set() - - - def __thread_loop__(self, job): - - while not self.shutdown_event.set(): - - self.job_event.wait(timeout = 1) - - if self.job_queue.qsize() > 0: - priority, data = self.job_queue.get() - - f, args = data - - f(*args, self.abort_event) - - self.job_queue.task_done() - - while self.abort_event.is_set(): - self.job_queue.get() - -@app.on_startup -def run_experimental_thread(): - pass - -@app.on_startup -def __init__(): - pass \ No newline at end of file diff --git a/src/gui.py b/src/gui.py index e7e25c6..6a573cd 100644 --- a/src/gui.py +++ b/src/gui.py @@ -6,16 +6,58 @@ 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 +from matplotlib.lines import Line2D from nicegui import ui, app import os import threading import time -from buffered_readout import create_buffer_instance - -_gui_instances = [] +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 +from qcodes.parameters import Parameter +import random +import os, sys +from tunerlog import TunerLog +from experiment_base import SweepParam, SweepLayer, Sweep +from autotuning_protocol import Protocol +from tunerlog import TunerLog + +logger = TunerLog('GUI') + + +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: @@ -25,24 +67,40 @@ def __init__(self): ''' Creates an instance of the tuner gui - - params: - self: ''' - global _FirstPass - + self.logger = TunerLog("TunerGUI") + self.start_time = time.monotonic() - print(threading.current_thread().name) - global _gui_instance - _gui_instances.append(self) + self.station = Station(config_file = "../configs/test_station.yaml") + self.station_lock = threading.Lock() - self.start_time = 0 + self.instrument_handler = create_buffer_instance(self.station, self.station_lock) - self.readout = create_buffer_instance() + self.experiment_handler = get_experiment_handler() + self.autotuning_handler = get_autotuning_handler() - self.readout.run() + def init_agilent(instrument: Instrument, *args): + instrument.NPLC(1.0) + instrument.range_auto('on') - def start(self): + def init_spi_rack(instrument: Instrument, *args): + + instrument.add_spi_module(8, 'D5a', 'module1') + instrument.add_spi_module(7, 'D5a', 'module2') + return + + 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.abort_signal = threading.Event() + + # The below methods define the layout of the GUI + + def root_page(self): """ The method that intialises the gui. As of now, it also defines the main page of the within itself. @@ -52,6 +110,7 @@ def start(self): """ self.header() + self.footer() # I tried putting these splitters into a separate function, but then the gui wouldn't start. @@ -64,7 +123,7 @@ def start(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: @@ -75,13 +134,10 @@ def start(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'): @@ -108,6 +164,37 @@ def start(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 + ) """ + + ui.button( + 'Run Test Sweep 2', + on_click=self.run_test_sweep_2 + ) + + """ ui.button( + 'Run Test Sweep 3', + on_click=self.run_test_sweep_3 + ) """ + + ui.button( + 'Run Bootstrapping', + 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: with ui.splitter(horizontal = True) as splitter2: @@ -118,16 +205,187 @@ def start(self): with splitter2.after: - ui.label('Logger Information').classes('ml-2') - - - 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) + 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.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 queued") + + sweep = Sweep( + layers=[ + 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' + ]) + ) + ) + + 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!") + + check_result() + + def run_test_sweep_2(self): + + self.debug_status.set_text("Running sweep...") + self.logger.info("Sweep job queued") + + sweep = Sweep( + layers=[ + SweepLayer( + targets=[ + SweepParam('spi_rack.module2.dac13.voltage', 0.0, 0.3) + ], + num_points=20, + measurement_time=0.05 + ), + SweepLayer( + targets=[ + SweepParam('spi_rack.module2.dac15.voltage', 0.0, 0.3) + ], + num_points=20, + measurement_time=0.05 + ), + SweepLayer( + targets=[ + SweepParam('spi_rack.module2.dac14.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' + ]), + ['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!") + + 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() + + def run_bootstrapping(self): + + self.debug_status.set_text("Running Bootstrapping...") + self.logger.info("Bootstrapping Jobs queued") + + 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 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): @@ -139,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): @@ -176,27 +434,6 @@ def results_plot_panel(self): axs[1].plot(xs, np.cos(xs)) fig.tight_layout() - 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() - def live_plot_window(self): """ @@ -207,34 +444,55 @@ 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('Signal (V)', fontsize = 16) + self.ax.tick_params(labelsize = 12) + fig.tight_layout() self.liveplot.update() def update_liveplot(self): - retval = self.readout.get_buffer() + colors = ['tab:blue', 'tab:red', 'tab:orange', 'tab:purple', 'tab:green'] + linestyles = ['-', '--', '-.', ':'] + + retval = self.instrument_handler.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_offset) - self.ax.set_xlim(min(times_offset), max(times_offset)) - self.ax.set_ylim(-0.5, 1.5) - + keys = list(retval.keys()) + 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.1, 1.4) + + self.ax.legend(self.lines, keys, ) self.liveplot.update() - ui.update() def experiment_progress_bar(self): @@ -251,7 +509,6 @@ def update_experiment_progress_bar(self): self.pb.delete() self.instr.enable() - def split_view(self, page1, page2, horizontal_split: bool = False): """ @@ -301,14 +558,20 @@ def on_autotune(self): pass + 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 + #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): - self.readout.join() - -@app.on_shutdown -def shutdown(): - global _gui_instances - for inst in _gui_instances: - inst.on_shutdown() + self.abort_signal.set() # Abort any currently running experiments. + self.instrument_handler.shutdown_instruments() + self.experiment_handler.experiment_thread.join() \ No newline at end of file diff --git a/src/instrument_handler.py b/src/instrument_handler.py new file mode 100644 index 0000000..e0889e1 --- /dev/null +++ b/src/instrument_handler.py @@ -0,0 +1,741 @@ +''' +File: instrument_handler.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 +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, 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 + +_BufferExists = False +_Instance = None + +logger = TunerLog("Instr. Control") + +def create_buffer_instance(station : Station, station_lock : threading.Lock): + global _Instance, logger + if _Instance is None: + _Instance = instrument_handler(station, station_lock) + + return _Instance + +def make_list(strings : str | List[str] | Literal['all']) -> List[str]: + 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) -> Any: + ... + +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: + logger.info("Timeout Reached!") + 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,\ + 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.job_queue : Queue[instrument_job] = 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 # 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 + logger.debug(f"Joining the instrument thread {self.thread_name}") + self.shutdown_signal.set() + 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): + ''' + Update the status of the thread. Private, do not call (thread safe). + ''' + 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). + ''' + 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: + 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: + # log the error + 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 = self.timefunc() + delta = tnow - tprev + sleep_time = self.measure_time - delta + if sleep_time > 0.001: + time.sleep(self.measure_time) + + 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 = [] + 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 = self.getattr_recursive(self.instrument, param_name) + 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): + 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 = self.getattr_recursive(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: + self.getattr_recursive(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 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: + already_monitored = param in self.parameters_private + if already_monitored: + continue + try: + # Test to make sure the requested parameter exists + qparam = self.getattr_recursive(self.instrument, param) + except Exception as e: + job.future.set_exception(e) + self._update_public_parameters() + return False + else: + # 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 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: + self.parameters_private.remove(param) + except Exception as e: + 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 + + +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 + 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 + + assert not _BufferExists, "Error: Readout buffer already exists!!" + + _BufferExists = True + + self.instrument_threads : Dict[str, instrument_thread] = {} + + self.heartbeats : Dict[str, float] = {} + + self.station = station + self.station_lock = station_lock + + self.global_shutdown = threading.Event() # A global shutdown signal for all child threads. + + 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]: + ''' + 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_stop <= 0.0: + t_stop = time.monotonic() + + t_start = t_stop - t_avg + + buffer_copies = self.get_buffer(var_name, blocking = True) + + 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 + + return retval + + 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 + + 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 + ValueError + This will get thrown if param_names are not able to be parsed + ''' + buffer_copies = {} + if param_names == 'all': + 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 + + def shutdown_instruments(self): + self.global_shutdown.set() + for inst_thread in self.instrument_threads.values(): + inst_thread.stop() + + + def add_instrument(self, name : str,\ + 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. + 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] | None = None + 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. + ''' + # 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] = instrument_thread(f"{name}Thread", name,\ + self.station,\ + self.station_lock,\ + self.global_shutdown,\ + init_func, *init_args) + + 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 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 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) + 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: + logger.warning("Cannot add parameters to instrument '%s': It does not exist!", inst_name) + return False + + 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: + ''' + 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. + ''' + inst_thread : instrument_thread | None = 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 = 60 # 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'): + logger.warning(f"Instrument thread {name} is dead with status {thread_status}") + return True \ No newline at end of file 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 fb6c542..9b2dead 100644 --- a/src/main.py +++ b/src/main.py @@ -2,13 +2,51 @@ 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 = None +logger = None + +@app.on_startup +def start_tuner_gui(): + global gui, logger + + print("Starting Program") + + logger = TunerLog("main") + logger.info("Starting GUI...") -if __name__ in {"__main__", "__mp_main__"}: - print("Creating") gui = tuner_gui() - print("Starting") - gui.start() + + print("Gui Startup Complete! Welcome to QAT!") + +@app.on_shutdown +def stop_tuner_gui(): + 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 + + 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) 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..f81f6fe --- /dev/null +++ b/src/tunerlog.py @@ -0,0 +1,155 @@ +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) + + client = self.element.client + print("CONNECT", client.id, hex(id(client))) + self.element.push(msg, classes=colors[level]) + except Exception as e: + print("UI handler failed:", repr(e)) + 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 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: + 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 diff --git a/src/write_control.py b/src/write_control.py deleted file mode 100644 index d6a8ac9..0000000 --- a/src/write_control.py +++ /dev/null @@ -1,1079 +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 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 - - intermediate = [] - done = set() - - prevvals = {} - gate_params = {} - gate_steps = {} - - # Now, we map the gate to the source and save the correspondance - - gate_to_source = {} - - for source_name, instrument in self.voltage_sources.items(): - for gate in self.voltage_source_names_check[source_name]: - gate_to_source[gate] = instrument - - for gate, target in voltage_configuration.items(): - - instrument = gate_to_source[gate] - param = getattr(instrument, gate) - - gate_params[gate] = param - prevvals[gate] = float(param.get()) - - step_param = getattr(instrument, f"{gate}_step", None) - - gate_steps[gate] = step_param() if step_param else stepsize - - # Now, we generate the ramp - - while len(done) < len(voltage_configuration): - - step = {} - - for gate, target in voltage_configuration.items(): - - if gate in done: - continue - - prev = prevvals[gate] - step_size = gate_steps[gate] - - dv = target - prev - - if abs(dv) <= step_size: - step[gate] = target - done.add(gate) - else: - step[gate] = prev + step_size * (1 if dv > 0 else -1) - - prevvals[gate] = step[gate] - - intermediate.append(step) - - # Finally, we apply the ramp - - for step in intermediate: - - for gate, voltage in step.items(): - gate_params[gate].set(voltage) - - # 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 - - return None - - 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. - """ - - 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 startV is None: - maxV = 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.") - - # Set up gate sweeps - - num_steps = self.calculate_num_of_steps(startV, endV, dV) - - gates_involved = gate - - self.logger.info(f"setting {gates_involved} to {startV} V") - - self.set_voltage_configuration(gates_involved, startV) - - intermediate = [] - done = set() - - prevvals = {} - gate_params = {} - gate_steps = {} - - # Now, we map the gate to the source and save the correspondance - - gate_to_source = {} - - for source_name, instrument in self.voltage_sources.items(): - for gate in self.voltage_source_names_check[source_name]: - gate_to_source[gate] = instrument - - for gate, target in voltage_configuration.items(): - - instrument = gate_to_source[gate] - param = getattr(instrument, gate) - - gate_params[gate] = param - prevvals[gate] = float(param.get()) - - step_param = getattr(instrument, f"{gate}_step", None) - - gate_steps[gate] = step_param() if step_param else dV - - # Now, we generate the ramp - - while len(done) < len(voltage_configuration): - - step = {} - - for gate, target in voltage_configuration.items(): - - if gate in done: - continue - - prev = prevvals[gate] - step_size = gate_steps[gate] - - dv = target - prev - - if abs(dv) <= step_size: - step[gate] = target - done.add(gate) - else: - step[gate] = prev + step_size * (1 if dv > 0 else -1) - - prevvals[gate] = step[gate] - - intermediate.append(step) - - # Finally, we apply the ramp - - for step in intermediate: - - for gate, voltage in step.items(): - gate_params[gate].set(voltage) - - # 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 - - return None - - 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