From 09855514861b6676aa52d4da792e0eb793fa40a3 Mon Sep 17 00:00:00 2001 From: G Adam Cox Date: Tue, 31 Jan 2023 20:06:11 -0800 Subject: [PATCH 01/14] Modifies DAQ sampler objects for purpose of improved data storage. Changes interface class to abstract base class. Creates sample_counts, sample_count_rate and yield_count_rate functions in base class. Sample_counts must be implemented by subclasses. sample_counts implemented in RandomRateCounter --- src/qt3utils/datagenerators/daqsamplers.py | 122 ++++++++++++--------- 1 file changed, 69 insertions(+), 53 deletions(-) diff --git a/src/qt3utils/datagenerators/daqsamplers.py b/src/qt3utils/datagenerators/daqsamplers.py index ef23dc85..960daca6 100644 --- a/src/qt3utils/datagenerators/daqsamplers.py +++ b/src/qt3utils/datagenerators/daqsamplers.py @@ -1,5 +1,6 @@ import time import logging +import abc import numpy as np import nidaqmx @@ -8,26 +9,75 @@ logger = logging.getLogger(__name__) -class RateCounterInterface: +class RateCounterBase(abc.ABC): + + def __init__(self): + self.clock_rate = 1 # default clock rate + self.running = False + def stop(self): - pass + """ + subclasses may override this for custom behavior + """ + self.running = False def start(self): - pass + """ + subclasses may override this for custom behavior + """ + self.running = True def close(self): + """ + subclasses may override this for custom behavior + """ pass - def sample_count_rate(self, n_samples = None): + @abc.abstractmethod + def sample_counts(self, n_samples = 1) -> np.ndarray: + """ + Should return a numpy array of size n_samples, with each row being + an array (or tuple) of two values, The first value is equal to the number of counts, + and the second value is the number of clock samples that were used to measure the counts. + + Example, if n_samples = 3 + data = [ + [22, 5], # 22 counts were observed in 5 clock samples + [24, 5], + [20, 4] # this data indicates there was an error with data acquisition - 4 clock samples were observed. + ] + """ pass + def sample_count_rate(self, data_counts: np.ndarray): + """ + Converts the output of sample_counts to a count rate. Expects data_counts to be a 2d numpy array + + Under normal conditions, will return a numpy array of size n_samples, with values + equal to the estimated count rate. + + However, there are two possible outcomes if there are errors with the data (which can be caused by NIDAQ errors) + + 1. return a numpy array of count rate measurements of size 0 < N < n_samples (if there's partial error) + 2. return a numpy array of size N = n_samples with value of np.nan if no data are returned + + If the NiDAQ is configured properly and sufficient time is allowed for data to be + acquired per batch, it's very unlikely that any errors will occur. + """ + + _data = data_counts[np.where(data_counts[:, 1] > 0)] #removes all rows where no data were acquired + if _data.shape[0] > 0: + return self.clock_rate * _data[:, 0]/_data[:, 1] + else: + return np.nan*np.ones(len(data_counts)) + def yield_count_rate(self): - #this should be a generator function - #yield np.mean(self.sample_count_rate()) - pass + while self.running: + count_data = self.sample_counts() + yield np.mean(self.sample_count_rate(count_data)) -class RandomRateCounter(RateCounterInterface): +class RandomRateCounter(RateCounterBase): ''' This random source acts like a light source with variable intensity. @@ -35,6 +85,7 @@ class RandomRateCounter(RateCounterInterface): This is similar to a PL source moving in and out of focus. ''' def __init__(self): + super().__init__() self.default_offset = 100 self.signal_noise_amp = 0.2 self.possible_offset_values = np.arange(0, 1000, 50) @@ -43,16 +94,10 @@ def __init__(self): self.current_direction = 1 self.running = False - def stop(self): - self.running = False - - def start(self): - self.running = True - - def close(self): - pass - - def sample_count_rate(self, n_samples = 1): + def sample_counts(self, n_samples = 1): + """ + Returns a random number of counts + """ if np.random.random(1)[0] < 0.05: if np.random.random(1)[0] < 0.1: self.current_direction = -1 * self.current_direction @@ -62,14 +107,12 @@ def sample_count_rate(self, n_samples = 1): self.current_offset = self.default_offset self.current_direction = 1 - return self.signal_noise_amp*self.current_offset*np.random.random(n_samples) + self.current_offset - - def yield_count_rate(self): - while self.running: - yield np.mean(self.sample_count_rate()) + counts = self.signal_noise_amp*self.current_offset*np.random.random(n_samples) + self.current_offset + count_size = np.ones(n_samples) + return np.column_stack((counts, count_size)) -class NiDaqDigitalInputRateCounter(RateCounterInterface): +class NiDaqDigitalInputRateCounter(RateCounterBase): def __init__(self, daq_name = 'Dev1', signal_terminal = 'PFI0', @@ -80,7 +123,7 @@ def __init__(self, daq_name = 'Dev1', signal_counter = 'ctr2', trigger_terminal = None, ): - + super().__init__() self.daq_name = daq_name self.signal_terminal = signal_terminal self.clock_rate = clock_rate @@ -235,7 +278,7 @@ def sample_counts(self, n_samples = 1): count_rate = clock_rate * data[:,0]/data[:,1] ''' - data =np.zeros((n_samples, 2)) + data = np.zeros((n_samples, 2)) for i in range(n_samples): data_sample, samples_read = self._read_samples() if samples_read > 0: @@ -244,30 +287,3 @@ def sample_counts(self, n_samples = 1): logger.info(f'batch data (sum counts, num clock cycles per batch): {data[i]}') return data - def sample_count_rate(self, n_samples = 1): - ''' - Utilizes sample_counts method above and compute the count rate as described - in that method's docstring. - - Under normal conditions, will return a numpy array of size n_samples, with values - equal to the estimated count rate. - - However, there are two possible outcomes if there are errors reading the NiDAQ. - - 1. return a numpy array of count rate measurements of size 0 < N < n_samples (if there's partial error) - 2. return a numpy array of size N = n_samples with value of np.nan if no data are returned - - If the NiDAQ is configured properly and sufficient time is allowed for data to be - acquired per batch, it's very unlikely that any errors will occur. - ''' - - data = self.sample_counts(n_samples) - data = data[np.where(data[:,1] > 0)] - if data.shape[0] > 0: - return self.clock_rate * data[:,0]/data[:,1] - else: - return np.nan*np.ones(n_samples) - - def yield_count_rate(self): - while self.running: - yield np.mean(self.sample_count_rate()) From a8399c55a1a505f1a202a1d5b9410690877b23e8 Mon Sep 17 00:00:00 2001 From: G Adam Cox Date: Wed, 1 Feb 2023 12:51:51 -0800 Subject: [PATCH 02/14] Adds sample_counts function to BasePiezoScanner subclasses --- src/qt3utils/datagenerators/piezoscanner.py | 29 +++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/qt3utils/datagenerators/piezoscanner.py b/src/qt3utils/datagenerators/piezoscanner.py index 16f84293..40b939ec 100644 --- a/src/qt3utils/datagenerators/piezoscanner.py +++ b/src/qt3utils/datagenerators/piezoscanner.py @@ -70,6 +70,13 @@ def move_y(self): except ValueError as e: logger.info(f'out of range\n\n{e}') + @abc.abstractmethod + def sample_counts(self): + ''' + must return an array-like object + ''' + pass + @abc.abstractmethod def sample_count_rate(self): ''' @@ -168,8 +175,13 @@ def __init__(self, nidaqratecounter, stage_controller, num_data_samples_per_batc def set_num_data_samples_per_batch(self, N): self.nidaqratecounter.num_data_samples_per_batch = N - def sample_count_rate(self): - return self.nidaqratecounter.sample_count_rate(self.num_daq_batches) + def sample_counts(self): + return self.nidaqratecounter.sample_counts(self.num_daq_batches) + + def sample_count_rate(self, data_counts=None): + if data_counts is None: + data_counts = self.sample_counts() + return self.nidaqratecounter.sample_count_rate(data_counts) def stop(self): self.nidaqratecounter.stop() @@ -195,17 +207,24 @@ def __init__(self, stage_controller = None): self.possible_offset_values = np.arange(5000, 100000, 1000) self.current_offset = self.default_offset + self.clock_period = 0.09302010 # a totally random number def set_num_data_samples_per_batch(self, N): #for the random sampler, there is only one sample per batch. So, we set #number of batches here self.num_daq_batches = N - def sample_count_rate(self): - #time.sleep(.25) #simulate time for data acquisition + + def sample_counts(self): if np.random.random(1)[0] < 0.005: self.current_offset = np.random.choice(self.possible_offset_values) else: self.current_offset = self.default_offset - return self.signal_noise_amp*self.current_offset*np.random.random(self.num_daq_batches) + self.current_offset + return self.signal_noise_amp * self.current_offset * np.random.random( + self.num_daq_batches) + self.current_offset + + def sample_count_rate(self, data_counts = None): + if data_counts is None: + data_counts = self.sample_counts() + return data_counts / self.clock_period From c859ea82b36dc7352a00bc75693c7ad992c7eb15 Mon Sep 17 00:00:00 2001 From: G Adam Cox Date: Thu, 2 Feb 2023 17:26:54 -0800 Subject: [PATCH 03/14] changes sample_count_rate function to return a single count rate value. This is a somewhat breaking change and will require subsequent changes to other components --- src/qt3utils/datagenerators/daqsamplers.py | 24 ++++++++------------- src/qt3utils/datagenerators/piezoscanner.py | 18 ++++++++-------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/qt3utils/datagenerators/daqsamplers.py b/src/qt3utils/datagenerators/daqsamplers.py index 960daca6..d7466e45 100644 --- a/src/qt3utils/datagenerators/daqsamplers.py +++ b/src/qt3utils/datagenerators/daqsamplers.py @@ -52,29 +52,23 @@ def sample_counts(self, n_samples = 1) -> np.ndarray: def sample_count_rate(self, data_counts: np.ndarray): """ Converts the output of sample_counts to a count rate. Expects data_counts to be a 2d numpy array + of [[counts, clock_samples], [counts, clock_samples], ...] as is returned by sample_counts. - Under normal conditions, will return a numpy array of size n_samples, with values - equal to the estimated count rate. + Under normal conditions, will return a single value - However, there are two possible outcomes if there are errors with the data (which can be caused by NIDAQ errors) - - 1. return a numpy array of count rate measurements of size 0 < N < n_samples (if there's partial error) - 2. return a numpy array of size N = n_samples with value of np.nan if no data are returned - - If the NiDAQ is configured properly and sufficient time is allowed for data to be - acquired per batch, it's very unlikely that any errors will occur. + If the sum of all clock_samples is 0, will return np.nan. """ - - _data = data_counts[np.where(data_counts[:, 1] > 0)] #removes all rows where no data were acquired - if _data.shape[0] > 0: - return self.clock_rate * _data[:, 0]/_data[:, 1] + _data = np.sum(data_counts, axis=0) + if _data[1] > 0: + return self.clock_rate * _data[0]/_data[1] else: - return np.nan*np.ones(len(data_counts)) + return np.nan + def yield_count_rate(self): while self.running: count_data = self.sample_counts() - yield np.mean(self.sample_count_rate(count_data)) + yield self.sample_count_rate(count_data) class RandomRateCounter(RateCounterBase): diff --git a/src/qt3utils/datagenerators/piezoscanner.py b/src/qt3utils/datagenerators/piezoscanner.py index 40b939ec..76eb2efd 100644 --- a/src/qt3utils/datagenerators/piezoscanner.py +++ b/src/qt3utils/datagenerators/piezoscanner.py @@ -72,16 +72,16 @@ def move_y(self): @abc.abstractmethod def sample_counts(self): - ''' - must return an array-like object - ''' + """ + expectation is to return [[counts, clock_samples], [counts, clock_samples], ...] as is returned by daqsamplers.sample_counts. + """ pass @abc.abstractmethod def sample_count_rate(self): - ''' - must return an array-like object - ''' + """ + must return a single floating point value + """ pass @abc.abstractmethod @@ -101,7 +101,7 @@ def scan_axis(self, axis, min, max, step_size): if self.stage_controller: logger.info(f'go to position {axis}: {val:.2f}') self.stage_controller.go_to_position(**{axis:val}) - cr = np.mean(self.sample_count_rate()) + cr = self.sample_count_rate() scan.append(cr) logger.info(f'count rate: {cr}') if self.stage_controller: @@ -204,7 +204,7 @@ def __init__(self, stage_controller = None): super().__init__(stage_controller) self.default_offset = 350 self.signal_noise_amp = 0.2 - self.possible_offset_values = np.arange(5000, 100000, 1000) + self.possible_offset_values = np.arange(5000, 100000, 1000) # these create the "bright" positions self.current_offset = self.default_offset self.clock_period = 0.09302010 # a totally random number @@ -227,4 +227,4 @@ def sample_counts(self): def sample_count_rate(self, data_counts = None): if data_counts is None: data_counts = self.sample_counts() - return data_counts / self.clock_period + return np.sum(data_counts) / self.clock_period From 837e5f3df10f52c0dc5cf4102dceba8e9bef1064 Mon Sep 17 00:00:00 2001 From: G Adam Cox Date: Fri, 3 Feb 2023 10:54:32 -0800 Subject: [PATCH 04/14] Puts random sampler and nidaq sampler on equal footing. Moves common methods to base class. Only a _read_samples method and clock_rate should be implemeneted by subclasses. removes setting .running attribute in subclasses --- src/qt3utils/datagenerators/daqsamplers.py | 197 +++++++++++---------- 1 file changed, 106 insertions(+), 91 deletions(-) diff --git a/src/qt3utils/datagenerators/daqsamplers.py b/src/qt3utils/datagenerators/daqsamplers.py index d7466e45..4c3573d5 100644 --- a/src/qt3utils/datagenerators/daqsamplers.py +++ b/src/qt3utils/datagenerators/daqsamplers.py @@ -10,11 +10,12 @@ logger = logging.getLogger(__name__) class RateCounterBase(abc.ABC): - + """ + Subclasses must implement a clock_rate attribute or property. + """ def __init__(self): - self.clock_rate = 1 # default clock rate self.running = False - + self.clock_rate = 0 def stop(self): """ subclasses may override this for custom behavior @@ -34,27 +35,92 @@ def close(self): pass @abc.abstractmethod - def sample_counts(self, n_samples = 1) -> np.ndarray: + def _read_samples(self): """ - Should return a numpy array of size n_samples, with each row being - an array (or tuple) of two values, The first value is equal to the number of counts, - and the second value is the number of clock samples that were used to measure the counts. + subclasses must implement this method + + Should return total_counts, num_clock_samples + """ + pass + + def sample_counts(self, n_batches=1, sum_counts=True): + """ + Performs n_batches of batch reads from _read_samples method. + + This is useful when hardware (such as NIDAQ) is pre-configured to acquire a fixed number of samples + and the caller wishes to read more data than the number of samples acquired. + For example, if the NiDAQ is configured to acquire 1000 clock samples, but the caller + wishes to read 10000 samples, then this function may be called with n_batches=10. + + For each batch read (of size `num_data_samples_per_batch`), the + total counts are summed. Because it's possible (though unlikely) + for the hardware to return fewer than `num_data_samples_per_batch` measurements, + the actual number of data samples per batch are also recorded. + + If sum_counts is False, a numpy array of shape (n_batches, 2) is returned, where + the first element is the sum of the counts, and the second element is + the actual number of clock samples per batch. This may be useful for the caller if + they wish to perform their own averaging or other statistical analysis that may be time dependent. + + For example, if `num_data_samples_per_batch` is 5 and n_batches is 3, + (typical values are 100 and 10, 100 and 1, 1000 and 1, etc) - Example, if n_samples = 3 + reading counts from the NiDAQ may return + + #sample 1 + raw_counts_1 = [3,5,4,6,4] + sum_counts_1 = 22 + size_counts_1 = 5 + (22, 5) + #sample 2 + raw_counts_2 = [5,5,7,3,4] + sum_counts_2 = 24 + size_counts_2 = 5 + (24, 5) + #sample 3 + raw_counts_3 = [5,3,5,7] + sum_counts_3 = 20 + size_counts_2 = 4 + (20, 4) + + In this example, the numpy array is of shape (3, 2) and will be data = [ - [22, 5], # 22 counts were observed in 5 clock samples + [22, 5], [24, 5], - [20, 4] # this data indicates there was an error with data acquisition - 4 clock samples were observed. + [20, 4] ] + + If sum_counts is True, then will the total number of counts and total number of + clock samples read will be returned. + + np.sum(data, axis=0, keepdims=True). + + In the example above, this would be [[66, 14]]. + + With these data, and knowing the clock_rate, one can easily compute + the count rate. See sample_count_rate. """ - pass + + data = np.zeros((n_batches, 2)) + for i in range(n_batches): + data_sample, samples_read = self._read_samples() + if samples_read > 0: + data[i][0] = np.sum(data_sample[:samples_read]) + data[i][1] = samples_read + logger.info(f'batch data (sum counts, num clock cycles per batch): {data[i]}') + + if sum_counts: + return np.sum(data, axis=0, keepdims=True) + else: + return data def sample_count_rate(self, data_counts: np.ndarray): """ Converts the output of sample_counts to a count rate. Expects data_counts to be a 2d numpy array - of [[counts, clock_samples], [counts, clock_samples], ...] as is returned by sample_counts. + of [[counts, clock_samples], [counts, clock_samples], ...] or a 2d array with one row: [[counts, clock_samples]] + as is returned by sample_counts. - Under normal conditions, will return a single value + Returns the count rate in counts/second = clock_rate * total counts/ total clock_samples) If the sum of all clock_samples is 0, will return np.nan. """ @@ -64,7 +130,6 @@ def sample_count_rate(self, data_counts: np.ndarray): else: return np.nan - def yield_count_rate(self): while self.running: count_data = self.sample_counts() @@ -78,32 +143,41 @@ class RandomRateCounter(RateCounterBase): This is similar to a PL source moving in and out of focus. ''' - def __init__(self): + def __init__(self, simulate_single_light_source=False, num_data_samples_per_batch=10): super().__init__() self.default_offset = 100 - self.signal_noise_amp = 0.2 - self.possible_offset_values = np.arange(0, 1000, 50) + self.signal_noise_amp = 0.2 self.current_offset = self.default_offset self.current_direction = 1 - self.running = False + self.clock_rate = 0.9302010 # a totally random number :P + self.simulate_single_light_source = simulate_single_light_source + self.possible_offset_values = np.arange(5000, 100000, 1000) # these create the "bright" positions + self.num_data_samples_per_batch = num_data_samples_per_batch - def sample_counts(self, n_samples = 1): + def _read_samples(self): """ Returns a random number of counts """ - if np.random.random(1)[0] < 0.05: - if np.random.random(1)[0] < 0.1: - self.current_direction = -1 * self.current_direction - self.current_offset += self.current_direction*np.random.choice(self.possible_offset_values) + if self.simulate_single_light_source: + if np.random.random(1)[0] < 0.005: + self.current_offset = np.random.choice(self.possible_offset_values) + else: + self.current_offset = self.default_offset + + else: + if np.random.random(1)[0] < 0.05: + if np.random.random(1)[0] < 0.1: + self.current_direction = -1 * self.current_direction + self.current_offset += self.current_direction*np.random.choice(self.possible_offset_values) - if self.current_offset < self.default_offset: - self.current_offset = self.default_offset - self.current_direction = 1 + if self.current_offset < self.default_offset: + self.current_offset = self.default_offset + self.current_direction = 1 - counts = self.signal_noise_amp*self.current_offset*np.random.random(n_samples) + self.current_offset - count_size = np.ones(n_samples) - return np.column_stack((counts, count_size)) + counts = self.signal_noise_amp * self.current_offset * np.random.random(self.num_data_samples_per_batch) + self.current_offset + + return counts, self.num_data_samples_per_batch class NiDaqDigitalInputRateCounter(RateCounterBase): @@ -126,7 +200,6 @@ def __init__(self, daq_name = 'Dev1', self.read_write_timeout = read_write_timeout self.num_data_samples_per_batch = num_data_samples_per_batch self.trigger_terminal = trigger_terminal - self.running = False self.read_lock = False @@ -188,7 +261,6 @@ def _read_samples(self): self.read_lock = False return data_buffer, samples_read - def start(self): if self.running: self.stop() @@ -208,12 +280,12 @@ def _burn_and_log_exception(self, f): def stop(self): if self.running: while self.read_lock: - time.sleep(0.1) #wait for current read to complete + time.sleep(0.1) # wait for current read to complete if self.nidaq_config.clock_task: self._burn_and_log_exception(self.nidaq_config.clock_task.stop) - self._burn_and_log_exception(self.nidaq_config.clock_task.close) #close the task to free resource on NIDAQ - #self._burn_and_log_exception(self.nidaq_config.counter_task.stop) #will need to stop task if we move to continuous buffered acquisition + self._burn_and_log_exception(self.nidaq_config.clock_task.close) # close the task to free resource on NIDAQ + # self._burn_and_log_exception(self.nidaq_config.counter_task.stop) # will need to stop task if we move to continuous buffered acquisition self._burn_and_log_exception(self.nidaq_config.counter_task.close) self.running = False @@ -221,63 +293,6 @@ def stop(self): def close(self): self.stop() - def sample_counts(self, n_samples = 1): - ''' - Performs n_samples of batch reads from the NiDAQ. - - For each batch read (of size `num_data_samples_per_batch`), the - total counts are summed. Additionally, because it's possible (though unlikely) - for the NiDAQ to return fewer than `num_data_samples_per_batch` measurements, - the actual number of data samples per batch are also recorded. - - Finally, a numpy array of shape (n_samples, 2) is returned, where - the first element is the sum of the counts, and the second element is - the actual number of data samples per batch. - - For example, if `num_data_samples_per_batch` is 5 and n_samples is 3, - (typical values are 100 and 10, 100 and 1, 1000 and 1, etc) - - reading counts from the NiDAQ may return - - #sample 1 - raw_counts_1 = [3,5,4,6,4] - sum_counts_1 = 22 - size_counts_1 = 5 - (22, 5) - #sample 2 - raw_counts_2 = [5,5,7,3,4] - sum_counts_2 = 24 - size_counts_2 = 5 - (24, 5) - #sample 3 - raw_counts_3 = [5,3,5,7] - sum_counts_3 = 20 - size_counts_2 = 4 - (20, 4) - - In this example, the numpy array is of shape (3, 2) and will be - data = [ - [22, 5], - [24, 5], - [20, 4] - ] - - With these data, and knowing the clock_rate, one can easily compute - the count rate - #removes rows where num samples per batch were zero (which would be a bug in the code) - data = data[np.where(data[:,1] > 0)] - #count rate is the mean counts per clock cycle multiplied by the clock rate. - count_rate = clock_rate * data[:,0]/data[:,1] - ''' - - data = np.zeros((n_samples, 2)) - for i in range(n_samples): - data_sample, samples_read = self._read_samples() - if samples_read > 0: - data[i][0] = np.sum(data_sample[:samples_read]) - data[i][1] = samples_read - logger.info(f'batch data (sum counts, num clock cycles per batch): {data[i]}') - return data From 670aefe57ecace349df38eacb7ba0fbe2012442c Mon Sep 17 00:00:00 2001 From: G Adam Cox Date: Fri, 3 Feb 2023 11:22:01 -0800 Subject: [PATCH 05/14] Simplifies scanning object to generic CounterAndScanner Allows to be instantiated with an implementation of a RateCounterBase class. RandomRateCounter and NiDaqDigitalInputRateCounter are subclasses. Also, CounterAndScanner must be implemented with a stage_controller object that is a subclass of nipiezojenapy.BaseControl. (This is poor choice, as we should probably define an interface for a scanner and then an implementation that supports nipiezojenapy. This would allow for different scanners to be used in the future. See Issue #79) --- src/applications/piezoscan.py | 25 ++-- src/qt3utils/datagenerators/piezoscanner.py | 149 ++++++-------------- 2 files changed, 59 insertions(+), 115 deletions(-) diff --git a/src/applications/piezoscan.py b/src/applications/piezoscan.py index 1defffac..30203d9a 100644 --- a/src/applications/piezoscan.py +++ b/src/applications/piezoscan.py @@ -1,18 +1,13 @@ -import time import argparse -import collections import tkinter as tk -import tkinter.ttk as ttk import logging import datetime from threading import Thread import numpy as np -import scipy.optimize import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk import matplotlib -matplotlib.use('Agg') import nidaqmx import qt3utils.nidaq @@ -21,6 +16,8 @@ import qt3utils.pulsers.pulseblaster import nipiezojenapy +matplotlib.use('Agg') + parser = argparse.ArgumentParser(description='NI DAQ (PCIx 6363) / Jena Piezo Scanner', formatter_class=argparse.ArgumentDefaultsHelpFormatter) @@ -86,10 +83,10 @@ def __init__(self, mplcolormap = 'Reds'): def update(self, model): if self.log_data: - data = np.log10(model.data) + data = np.log10(model.scanned_count_rate) data[np.isinf(data)] = 0 #protect against +-inf else: - data = model.data + data = model.scanned_count_rate self.artist = self.ax.imshow(data, cmap=self.cmap, extent=[model.xmin, model.xmax + model.step_size, @@ -117,6 +114,7 @@ def onclick(self, event): #todo: draw a circle around clicked point? Maybe with a high alpha, so that its faint self.onclick_callback(event) + class SidePanel(): def __init__(self, root, scan_range): frame = tk.Frame(root) @@ -231,7 +229,6 @@ def __init__(self, root, scan_range): self.log10Button = tk.Button(frame, text="Log10") self.log10Button.grid(row=row, column=2, pady=(2,15)) - def update_go_to_position(self, x = None, y = None, z = None): if x is not None: self.go_to_x_position_text.set(np.round(x,4)) @@ -349,14 +346,14 @@ def go_to_z(self, event = None): def set_color_map(self, event = None): #Is there a way for this function to exist entirely in the view code instead of here? self.view.scan_view.cmap = self.view.sidepanel.mpl_color_map_entry.get() - if len(self.model.data) > 0: + if len(self.model.scanned_count_rate) > 0: self.view.scan_view.update(self.model) self.view.canvas.draw() def log_scan_image(self, event = None): #Is there a way for this function to exist entirely in the view code instead of here? self.view.scan_view.log_data = not self.view.scan_view.log_data - if len(self.model.data) > 0: + if len(self.model.scanned_count_rate) > 0: self.view.scan_view.update(self.model) self.view.canvas.draw() @@ -459,7 +456,7 @@ def save_scan(self, event = None): if afile is None or afile == '': return #selection was canceled. with open(afile, 'wb') as f_object: - np.save(f_object, self.model.data) + np.save(f_object, self.model.scanned_count_rate) self.view.sidepanel.saveScanButton['state'] = 'normal' @@ -531,7 +528,8 @@ def on_closing(self): def build_data_scanner(): if args.randomtest: stage_controller = nipiezojenapy.BaseControl() - scanner = datasources.RandomPiezoScanner(stage_controller=stage_controller) + data_acq = datasources.RandomRateCounter(simulate_single_light_source=True, + num_data_samples_per_batch=args.num_data_samples_per_batch) else: stage_controller = nipiezojenapy.PiezoControl(device_name = args.daq_name, write_channels = args.piezo_write_channels.split(','), @@ -545,7 +543,7 @@ def build_data_scanner(): args.rwtimeout, args.signal_counter) - scanner = datasources.NiDaqPiezoScanner(data_acq, stage_controller) + scanner = qt3utils.datagenerators.piezoscanner.CounterAndScanner(data_acq, stage_controller) return scanner @@ -554,5 +552,6 @@ def main(): tkapp = MainTkApplication(build_data_scanner()) tkapp.run() + if __name__ == '__main__': main() diff --git a/src/qt3utils/datagenerators/piezoscanner.py b/src/qt3utils/datagenerators/piezoscanner.py index 76eb2efd..07f78a22 100644 --- a/src/qt3utils/datagenerators/piezoscanner.py +++ b/src/qt3utils/datagenerators/piezoscanner.py @@ -1,4 +1,3 @@ -import abc import numpy as np import scipy.optimize import time @@ -10,29 +9,33 @@ def gauss(x, *p): C, mu, sigma, offset = p return C*np.exp(-(x-mu)**2/(2.*sigma**2)) + offset -class BasePiezoScanner(abc.ABC): - def __init__(self, stage_controller = None): - self.running = False +class CounterAndScanner: + def __init__(self, rate_counter, stage_controller): + self.running = False self.current_y = 0 self.ymin = 0.0 self.ymax = 80.0 self.xmin = 0.0 self.xmax = 80.0 self.step_size = 0.5 - self.raster_line_pause = 0.0 + self.raster_line_pause = 0.150 # wait 150ms for the piezo stage to settle before a line scan - self.data = [] - self.stage_controller = stage_controller + self.scanned_raw_counts = [] + self.scanned_count_rate = [] - self.num_daq_batches = 1 #could change to 10 if want 10x more samples for each position + self.stage_controller = stage_controller + self.rate_counter = rate_counter + self.num_daq_batches = 1 # could change to 10 if want 10x more samples for each position def stop(self): + self.rate_counter.stop() self.running = False def start(self): self.running = True + self.rate_counter.start() def set_to_starting_position(self): self.current_y = self.ymin @@ -40,7 +43,18 @@ def set_to_starting_position(self): self.stage_controller.go_to_position(x = self.xmin, y = self.ymin) def close(self): - return + self.rate_counter.close() + + def set_num_data_samples_per_batch(self, N): + self.rate_counter.num_data_samples_per_batch = N + + def sample_counts(self): + return self.rate_counter.sample_counts(self.num_daq_batches) + + def sample_count_rate(self, data_counts=None): + if data_counts is None: + data_counts = self.sample_counts() + return self.rate_counter.sample_count_rate(data_counts) def set_scan_range(self, xmin, xmax, ymin, ymax): if self.stage_controller: @@ -70,47 +84,40 @@ def move_y(self): except ValueError as e: logger.info(f'out of range\n\n{e}') - @abc.abstractmethod - def sample_counts(self): - """ - expectation is to return [[counts, clock_samples], [counts, clock_samples], ...] as is returned by daqsamplers.sample_counts. + def scan_x(self): """ - pass + Scans the x axis from xmin to xmax in steps of step_size. - @abc.abstractmethod - def sample_count_rate(self): + Stores results in self.scanned_raw_counts and self.scanned_count_rate. """ - must return a single floating point value - """ - pass - - @abc.abstractmethod - def set_num_data_samples_per_batch(self, N): - pass - - def scan_x(self): - - scan = self.scan_axis('x', self.xmin, self.xmax, self.step_size) - self.data.append(scan) + raw_counts_for_axis = self.scan_axis('x', self.xmin, self.xmax, self.step_size) + self.scanned_raw_counts.append(raw_counts_for_axis) + self.scanned_count_rate.append([self.sample_count_rate(raw_counts) for raw_counts in raw_counts_for_axis]) def scan_axis(self, axis, min, max, step_size): - scan = [] + """ + Moves the stage along the specified axis from min to max in steps of step_size. + Returns a list of raw counts from the scan in the shape + [[[counts, clock_samples]], [[counts, clock_samples]], ...] where each [[counts, clock_samples]] is the + result of a single call to sample_counts at each scan position along the axis. + """ + raw_counts = [] self.stage_controller.go_to_position(**{axis:min}) time.sleep(self.raster_line_pause) for val in np.arange(min, max, step_size): if self.stage_controller: logger.info(f'go to position {axis}: {val:.2f}') self.stage_controller.go_to_position(**{axis:val}) - cr = self.sample_count_rate() - scan.append(cr) - logger.info(f'count rate: {cr}') + _raw_counts = self.sample_counts() + raw_counts.append(_raw_counts) + logger.info(f'raw counts, total clock samples: {_raw_counts}') if self.stage_controller: logger.info(f'current position: {self.stage_controller.get_current_position()}') - return scan + return raw_counts def reset(self): - self.data = [] + self.scanned_raw_counts = [] def optimize_position(self, axis, center_position, width = 2, step_size = 0.25): ''' @@ -147,15 +154,16 @@ def optimize_position(self, axis, center_position, width = 2, step_size = 0.25): max_val = np.min([max_val, 80.0]) self.start() - data = self.scan_axis(axis, min_val, max_val, step_size) + raw_counts = self.scan_axis(axis, min_val, max_val, step_size) self.stop() axis_vals = np.arange(min_val, max_val, step_size) + count_rate = self.sample_count_rate(raw_counts) - optimal_position = axis_vals[np.argmax(data)] + optimal_position = axis_vals[np.argmax(count_rate)] coeff = None - params = [np.max(data), optimal_position, 1.0, np.min(data)] + params = [np.max(count_rate), optimal_position, 1.0, np.min(count_rate)] try: - coeff, var_matrix = scipy.optimize.curve_fit(gauss, axis_vals, data, p0=params) + coeff, var_matrix = scipy.optimize.curve_fit(gauss, axis_vals, count_rate, p0=params) optimal_position = coeff[1] # ensure that the optimal position is within the scan range optimal_position = np.max([min_val, optimal_position]) @@ -163,68 +171,5 @@ def optimize_position(self, axis, center_position, width = 2, step_size = 0.25): except RuntimeError as e: print(e) - return data, axis_vals, optimal_position, coeff - -class NiDaqPiezoScanner(BasePiezoScanner): - def __init__(self, nidaqratecounter, stage_controller, num_data_samples_per_batch = 50): - super().__init__(stage_controller) - self.nidaqratecounter = nidaqratecounter - self.raster_line_pause = 0.150 #wait 150ms for the piezo stage to settle before a line scan - self.set_num_data_samples_per_batch(num_data_samples_per_batch) + return count_rate, axis_vals, optimal_position, coeff - def set_num_data_samples_per_batch(self, N): - self.nidaqratecounter.num_data_samples_per_batch = N - - def sample_counts(self): - return self.nidaqratecounter.sample_counts(self.num_daq_batches) - - def sample_count_rate(self, data_counts=None): - if data_counts is None: - data_counts = self.sample_counts() - return self.nidaqratecounter.sample_count_rate(data_counts) - - def stop(self): - self.nidaqratecounter.stop() - super().stop() - - def start(self): - super().start() - self.nidaqratecounter.start() - - def close(self): - super().close() - self.nidaqratecounter.close() - -class RandomPiezoScanner(BasePiezoScanner): - ''' - This random scanner acts like it finds bright light sources - at random positions across a scan. - ''' - def __init__(self, stage_controller = None): - super().__init__(stage_controller) - self.default_offset = 350 - self.signal_noise_amp = 0.2 - self.possible_offset_values = np.arange(5000, 100000, 1000) # these create the "bright" positions - - self.current_offset = self.default_offset - self.clock_period = 0.09302010 # a totally random number - - def set_num_data_samples_per_batch(self, N): - #for the random sampler, there is only one sample per batch. So, we set - #number of batches here - self.num_daq_batches = N - - - def sample_counts(self): - if np.random.random(1)[0] < 0.005: - self.current_offset = np.random.choice(self.possible_offset_values) - else: - self.current_offset = self.default_offset - - return self.signal_noise_amp * self.current_offset * np.random.random( - self.num_daq_batches) + self.current_offset - - def sample_count_rate(self, data_counts = None): - if data_counts is None: - data_counts = self.sample_counts() - return np.sum(data_counts) / self.clock_period From 40619df782d55e7bc3b6d98801e0432d999360f7 Mon Sep 17 00:00:00 2001 From: G Adam Cox Date: Fri, 3 Feb 2023 11:40:01 -0800 Subject: [PATCH 06/14] Changes internal variable from model to counter_scanner for clarity. --- src/applications/piezoscan.py | 60 +++++++++++++++++------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/applications/piezoscan.py b/src/applications/piezoscan.py index 30203d9a..07c81d81 100644 --- a/src/applications/piezoscan.py +++ b/src/applications/piezoscan.py @@ -291,11 +291,11 @@ def show_optimization_plot(self, title, old_opt_value, class MainTkApplication(): - def __init__(self, data_model): + def __init__(self, counter_scanner): self.root = tk.Tk() - self.model = data_model - scan_range = [data_model.stage_controller.minimum_allowed_position, - data_model.stage_controller.maximum_allowed_position] + self.counter_scanner = counter_scanner + scan_range = [counter_scanner.stage_controller.minimum_allowed_position, + counter_scanner.stage_controller.maximum_allowed_position] self.view = MainApplicationView(self.root, scan_range) self.view.sidepanel.startButton.bind("