diff --git a/README.md b/README.md index 762d1a0..ed0e941 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,27 @@ for i in range(0, 1000): ppk2_test.stop_measuring() ``` +## Multiprocessing version +Regular version will struggle to get all samples. Multiprocessing version spawns another process in the background which polls the device constantly for new samples and holds the last 10 seconds of data (default, configurable) in the buffer so get_data() can be called less frequently. + +``` +ppk2_test = PPK2_MP("/dev/ttyACM3") # serial port will be different for you +ppk2_test.get_modifiers() +ppk2_test.use_source_meter() # set source meter mode +ppk2_test.set_source_voltage(3300) # set source voltage in mV +ppk2_test.start_measuring() # start measuring + +# read measured values in a for loop like this: +for i in range(0, 10): + read_data = ppk2_test.get_data() + if read_data != b'': + samples = ppk2_test.get_samples(read_data) + print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") + time.sleep(1) # we can do other stuff while the background process if fetching samples + +ppk2_test.stop_measuring() + +``` ## Licensing pp2-api-python is licensed under [GPL V2 license](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html). diff --git a/example.py b/example.py index 6a20967..e89ef7b 100644 --- a/example.py +++ b/example.py @@ -10,17 +10,20 @@ from ppk2_api.ppk2_api import PPK2_API ppk2s_connected = PPK2_API.list_devices() -if(len(ppk2s_connected) == 1): - ppk2_port = ppk2s_connected[0] - print(f'Found PPK2 at {ppk2_port}') +if len(ppk2s_connected) == 1: + ppk2_port = ppk2s_connected[0][0] + ppk2_serial = ppk2s_connected[0][1] + print(f"Found PPK2 at {ppk2_port} with serial number {ppk2_serial}") else: - print(f'Too many connected PPK2\'s: {ppk2s_connected}') + print(f"Too many connected PPK2's: {ppk2s_connected}") exit() -ppk2_test = PPK2_API(ppk2_port) +ppk2_test = PPK2_API(ppk2_port, timeout=1, write_timeout=1, exclusive=True) ppk2_test.get_modifiers() -ppk2_test.use_ampere_meter() # set ampere meter mode -ppk2_test.toggle_DUT_power("OFF") # disable DUT power +ppk2_test.set_source_voltage(3300) + +ppk2_test.use_source_meter() # set source meter mode +ppk2_test.toggle_DUT_power("ON") # enable DUT power ppk2_test.start_measuring() # start measuring # measurements are a constant stream of bytes @@ -30,18 +33,38 @@ for i in range(0, 1000): read_data = ppk2_test.get_data() if read_data != b'': - samples = ppk2_test.get_samples(read_data) + samples, raw_digital = ppk2_test.get_samples(read_data) print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") + + # Raw digital contains the raw digital data from the PPK2 + # The number of raw samples is equal to the number of samples in the samples list + # We have to process the raw digital data to get the actual digital data + digital_channels = ppk2_test.digital_channels(raw_digital) + for ch in digital_channels: + # Print last 10 values of each channel + print(ch[-10:]) + print() time.sleep(0.01) -ppk2_test.toggle_DUT_power("ON") +ppk2_test.toggle_DUT_power("OFF") # disable DUT power + +ppk2_test.use_ampere_meter() # set ampere meter mode ppk2_test.start_measuring() for i in range(0, 1000): read_data = ppk2_test.get_data() if read_data != b'': - samples = ppk2_test.get_samples(read_data) + samples, raw_digital = ppk2_test.get_samples(read_data) print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") - time.sleep(0.001) # lower time between sampling -> less samples read in one sampling period -ppk2_test.stop_measuring() \ No newline at end of file + # Raw digital contains the raw digital data from the PPK2 + # The number of raw samples is equal to the number of samples in the samples list + # We have to process the raw digital data to get the actual digital data + digital_channels = ppk2_test.digital_channels(raw_digital) + for ch in digital_channels: + # Print last 10 values of each channel + print(ch[-10:]) + print() + time.sleep(0.01) # lower time between sampling -> less samples read in one sampling period + +ppk2_test.stop_measuring() diff --git a/example_mp.py b/example_mp.py new file mode 100644 index 0000000..523dcd9 --- /dev/null +++ b/example_mp.py @@ -0,0 +1,78 @@ + +""" +Basic usage of PPK2 Python API - multiprocessing version. +The basic ampere mode sequence is: +1. read modifiers +2. set ampere mode +3. read stream of data +""" +import time +from ppk2_api.ppk2_api import PPK2_MP as PPK2_API + +ppk2s_connected = PPK2_API.list_devices() +if len(ppk2s_connected) == 1: + ppk2_port = ppk2s_connected[0][0] + ppk2_serial = ppk2s_connected[0][1] + print(f"Found PPK2 at {ppk2_port} with serial number {ppk2_serial}") +else: + print(f"Too many connected PPK2's: {ppk2s_connected}") + exit() + +ppk2_test = PPK2_API(ppk2_port, buffer_max_size_seconds=1, buffer_chunk_seconds=0.01, timeout=1, write_timeout=1, exclusive=True) +ppk2_test.get_modifiers() +ppk2_test.set_source_voltage(3300) + +""" +Source mode example +""" +ppk2_test.use_source_meter() # set source meter mode +ppk2_test.toggle_DUT_power("ON") # enable DUT power + +ppk2_test.start_measuring() # start measuring +# measurements are a constant stream of bytes +# the number of measurements in one sampling period depends on the wait between serial reads +# it appears the maximum number of bytes received is 1024 +# the sampling rate of the PPK2 is 100 samples per millisecond +while True: + read_data = ppk2_test.get_data() + if read_data != b'': + samples, raw_digital = ppk2_test.get_samples(read_data) + print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") + + # Raw digital contains the raw digital data from the PPK2 + # The number of raw samples is equal to the number of samples in the samples list + # We have to process the raw digital data to get the actual digital data + digital_channels = ppk2_test.digital_channels(raw_digital) + for ch in digital_channels: + # Print last 10 values of each channel + print(ch[-10:]) + print() + + time.sleep(0.001) + +ppk2_test.toggle_DUT_power("OFF") # disable DUT power +ppk2_test.stop_measuring() + +""" +Ampere mode example +""" +ppk2_test.use_ampere_meter() # set ampere meter mode + +ppk2_test.start_measuring() +while True: + read_data = ppk2_test.get_data() + if read_data != b'': + samples, raw_digital = ppk2_test.get_samples(read_data) + print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") + + # Raw digital contains the raw digital data from the PPK2 + # The number of raw samples is equal to the number of samples in the samples list + # We have to process the raw digital data to get the actual digital data + digital_channels = ppk2_test.digital_channels(raw_digital) + for ch in digital_channels: + # Print last 10 values of each channel + print(ch[-10:]) + print() + time.sleep(0.001) # lower time between sampling -> less samples read in one sampling period + +ppk2_test.stop_measuring() diff --git a/setup.py b/setup.py index 91ec04c..bab7e24 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ def read(*names, **kwargs): setup( name="ppk2-api", - version="0.0.1", + version="0.9.2", description="API for Nordic Semiconductor's Power Profiler Kit II (PPK 2).", url="https://github.com/IRNAS/ppk2-api-python", packages=find_packages("src"), diff --git a/src/power_profiler.py b/src/power_profiler.py index 2a22ebb..7af0f6f 100644 --- a/src/power_profiler.py +++ b/src/power_profiler.py @@ -5,7 +5,7 @@ # import numpy as np # import matplotlib.pyplot as plt # import matplotlib -from ppk2_api.ppk2_api import PPK2_API +from ppk2_api.ppk2_api import PPK2_MP as PPK2_API class PowerProfiler(): def __init__(self, serial_port=None, source_voltage_mV=3300, filename=None): @@ -14,21 +14,28 @@ def __init__(self, serial_port=None, source_voltage_mV=3300, filename=None): self.measurement_thread = None self.ppk2 = None - try: + print(f"Initing power profiler") + + # try: + if serial_port: + self.ppk2 = PPK2_API(serial_port) + else: + serial_port = self.discover_port() + print(f"Opening serial port: {serial_port}") if serial_port: self.ppk2 = PPK2_API(serial_port) - else: - serial_port = self.discover_port() - if serial_port: - self.ppk2 = PPK2_API(serial_port) + + try: ret = self.ppk2.get_modifiers() # try to read modifiers, if it fails serial port is probably not correct + print(f"Initialized ppk2 api: {ret}") except Exception as e: + print(f"Error initializing power profiler: {e}") ret = None raise e if not ret: self.ppk2 = None - #raise Exception(f"Error when initing PowerProfiler with serial port {serial_port}") + raise Exception(f"Error when initing PowerProfiler with serial port {serial_port}") else: self.ppk2.use_source_meter() @@ -36,6 +43,8 @@ def __init__(self, serial_port=None, source_voltage_mV=3300, filename=None): self.ppk2.set_source_voltage(self.source_voltage_mV) # set to 3.3V + print(f"Set power profiler source voltage: {self.source_voltage_mV}") + self.measuring = False self.current_measurements = [] @@ -73,12 +82,19 @@ def delete_power_profiler(self): self.measuring = False self.stop = True + print("Deleting power profiler") + if self.measurement_thread: + print(f"Joining measurement thread") self.measurement_thread.join() self.measurement_thread = None if self.ppk2: + print(f"Disabling ppk2 power") self.disable_power() + del self.ppk2 + + print(f"Deleted power profiler") def discover_port(self): """Discovers ppk2 serial port""" @@ -111,8 +127,7 @@ def measurement_loop(self): if self.measuring: # read data if currently measuring read_data = self.ppk2.get_data() if read_data != b'': - #samples = self.ppk2.get_samples(read_data) - samples = self._average_samples(self.ppk2.get_samples(read_data), 1024) # optionally average samples + samples = self.ppk2.get_samples(read_data) self.current_measurements += samples # can easily sum lists, will append individual data time.sleep(0.001) # TODO figure out correct sleep duration @@ -129,8 +144,8 @@ def start_measuring(self): """Start measuring""" if not self.measuring: # toggle measuring flag only if currently not measuring self.current_measurements = [] # reset current measurements - self.ppk2.start_measuring() # send command to ppk2 self.measuring = True # set internal flag + self.ppk2.start_measuring() # send command to ppk2 self.measurement_start_time = time.time() def stop_measuring(self): @@ -149,6 +164,9 @@ def get_min_current_mA(self): def get_max_current_mA(self): return max(self.current_measurements) / 1000 + def get_num_measurements(self): + return len(self.current_measurements) + def get_average_current_mA(self): """Returns average current of last measurement in mA""" if len(self.current_measurements) == 0: @@ -159,7 +177,7 @@ def get_average_current_mA(self): def get_average_power_consumption_mWh(self): """Return average power consumption of last measurement in mWh""" - average_current_mA = self.get_average_current_mA() # convert microamperes to milliamperes + average_current_mA = self.get_average_current_mA() average_power_mW = (self.source_voltage_mV / 1000) * average_current_mA # divide by 1000 as source voltage is in millivolts - this gives us milliwatts measurement_duration_h = self.get_measurement_duration_s() / 3600 # duration in seconds, divide by 3600 to get hours average_consumption_mWh = average_power_mW * measurement_duration_h @@ -174,9 +192,4 @@ def get_average_charge_mC(self): def get_measurement_duration_s(self): """Returns duration of measurement""" measurement_duration_s = (self.measurement_stop_time - self.measurement_start_time) # measurement duration in seconds - return measurement_duration_s - -# pp = PowerProfiler("/dev/ttyACM1") -# pp.start_measuring() -# time.sleep(10) -# pp.stop_measuring() \ No newline at end of file + return measurement_duration_s \ No newline at end of file diff --git a/src/ppk2_api/ppk2_api.py b/src/ppk2_api/ppk2_api.py index 3ec3d26..4b39a69 100644 --- a/src/ppk2_api/ppk2_api.py +++ b/src/ppk2_api/ppk2_api.py @@ -4,10 +4,13 @@ The official nRF Connect Power Profiler was used as a reference: https://github.com/NordicSemiconductor/pc-nrfconnect-ppk """ -import serial import time +import serial import struct import logging +import os +import queue +import threading class PPK2_Command(): """Serial command opcodes""" @@ -43,10 +46,14 @@ class PPK2_Modes(): class PPK2_API(): - def __init__(self, port): + def __init__(self, port: str, **kwargs): + ''' + port - port where PPK2 is connected + **kwargs - keyword arguments to pass to the pySerial constructor + ''' self.ser = None - self.ser = serial.Serial(port) + self.ser = serial.Serial(port, **kwargs) self.ser.baudrate = 9600 self.modifiers = { @@ -65,7 +72,7 @@ def __init__(self, port): self.vdd_low = 800 self.vdd_high = 5000 - self.current_vdd = 0 + self.current_vdd = None self.adc_mult = 1.8 / 163840 @@ -91,6 +98,9 @@ def __init__(self, port): def __del__(self): """Destructor""" try: + # reset device + self._write_serial((PPK2_Command.RESET,)) + if self.ser: self.ser.close() except Exception as e: @@ -182,11 +192,8 @@ def _generate_mask(self, bits, pos): mask = self._twos_comp(mask) return {"mask": mask, "pos": pos} - def _get_masked_value(self, value, meas): + def _get_masked_value(self, value, meas, is_bits=False): masked_value = (value & meas["mask"]) >> meas["pos"] - if meas["pos"] == 24: - if masked_value == 255: - masked_value = -1 return masked_value def _handle_raw_data(self, adc_value): @@ -198,16 +205,28 @@ def _handle_raw_data(self, adc_value): bits = self._get_masked_value(adc_value, self.MEAS_LOGIC) analog_value = self.get_adc_result( current_measurement_range, adc_result) * 10**6 - return analog_value + return analog_value, bits except Exception as e: print("Measurement outside of range!") - return None + return None, None @staticmethod def list_devices(): import serial.tools.list_ports + ports = serial.tools.list_ports.comports() - devices = [port.device for port in ports if port.product == 'PPK2'] + if os.name == "nt": + devices = [ + (port.device, port.serial_number[:8]) + for port in ports + if port.description.startswith("nRF Connect USB CDC ACM") and port.location.endswith("1") + ] + else: + devices = [ + (port.device, port.serial_number[:8]) + for port in ports + if port.product == "PPK2" and port.location.endswith("1") + ] return devices def get_data(self): @@ -223,11 +242,17 @@ def get_modifiers(self): return ret def start_measuring(self): - """Start continous measurement""" + """Start continuous measurement""" + if not self.current_vdd: + if self.mode == PPK2_Modes.SOURCE_MODE: + raise Exception("Output voltage not set!") + if self.mode == PPK2_Modes.AMPERE_MODE: + raise Exception("Input voltage not set!") + self._write_serial((PPK2_Command.AVERAGE_START, )) def stop_measuring(self): - """Stop continous measurement""" + """Stop continuous measurement""" self._write_serial((PPK2_Command.AVERAGE_STOP, )) def set_source_voltage(self, mV): @@ -311,6 +336,26 @@ def _digital_to_analog(self, adc_value): """Convert discrete value to analog value""" return int.from_bytes(adc_value, byteorder="little", signed=False) # convert reading to analog value + def digital_channels(self, bits): + """ + Convert raw digital data to digital channels. + + Returns a 2d matrix with 8 rows (one for each channel). Each row contains HIGH and LOW values for the selected channel. + """ + + # Prepare 2d matrix with 8 rows (one for each channel) + digital_channels = [[], [], [], [], [], [], [], []] + for sample in bits: + digital_channels[0].append((sample & 1) >> 0) + digital_channels[1].append((sample & 2) >> 1) + digital_channels[2].append((sample & 4) >> 2) + digital_channels[3].append((sample & 8) >> 3) + digital_channels[4].append((sample & 16) >> 4) + digital_channels[5].append((sample & 32) >> 5) + digital_channels[6].append((sample & 64) >> 6) + digital_channels[7].append((sample & 128) >> 7) + return digital_channels + def get_samples(self, buf): """ Returns list of samples read in one sampling period. @@ -322,13 +367,16 @@ def get_samples(self, buf): sample_size = 4 # one analog value is 4 bytes in size offset = self.remainder["len"] samples = [] + raw_digital_output = [] first_reading = ( self.remainder["sequence"] + buf[0:sample_size-offset])[:4] adc_val = self._digital_to_analog(first_reading) - measurement = self._handle_raw_data(adc_val) + measurement, bits = self._handle_raw_data(adc_val) if measurement is not None: samples.append(measurement) + if bits is not None: + raw_digital_output.append(bits) offset = sample_size - offset @@ -336,11 +384,145 @@ def get_samples(self, buf): next_val = buf[offset:offset + sample_size] offset += sample_size adc_val = self._digital_to_analog(next_val) - measurement = self._handle_raw_data(adc_val) + measurement, bits = self._handle_raw_data(adc_val) if measurement is not None: samples.append(measurement) + if bits is not None: + raw_digital_output.append(bits) self.remainder["sequence"] = buf[offset:len(buf)] self.remainder["len"] = len(buf)-offset - return samples # return list of samples, handle those lists in PPK2 API wrapper + # return list of samples and raw digital outputs + # handle those lists in PPK2 API wrapper + return samples, raw_digital_output + + +class PPK_Fetch(threading.Thread): + ''' + Background process for polling the data in multi-threaded variant + ''' + def __init__(self, ppk2, quit_evt, buffer_len_s=10, buffer_chunk_s=0.5): + super().__init__() + self._ppk2 = ppk2 + self._quit = quit_evt + + self.print_stats = False + self._stats = (None, None) + self._last_timestamp = 0 + + self._buffer_max_len = int(buffer_len_s * 100000 * 4) # 100k 4-byte samples per second + self._buffer_chunk = int(buffer_chunk_s * 100000 * 4) # put in the queue in chunks of 0.5s + + # round buffers to a whole sample + if self._buffer_max_len % 4 != 0: + self._buffer_max_len = (self._buffer_max_len // 4) * 4 + if self._buffer_chunk % 4 != 0: + self._buffer_chunk = (self._buffer_chunk // 4) * 4 + + self._buffer_q = queue.Queue() + + def run(self): + s = 0 + t = time.time() + local_buffer = b'' + while not self._quit.is_set(): + d = PPK2_API.get_data(self._ppk2) + tm_now = time.time() + local_buffer += d + while len(local_buffer) >= self._buffer_chunk: + # FIXME: check if lock might be needed when discarding old data + self._buffer_q.put(local_buffer[:self._buffer_chunk]) + while self._buffer_q.qsize()>self._buffer_max_len/self._buffer_chunk: + self._buffer_q.get() + local_buffer = local_buffer[self._buffer_chunk:] + self._last_timestamp = tm_now + + # calculate stats + s += len(d) + dt = tm_now - t + if dt >= 0.1: + if self.print_stats: + print(f"Samples: {s}, delta time: {dt}") + self._stats = (s, dt) + s = 0 + t = tm_now + + time.sleep(0.0001) + + # process would hang on join() if there's data in the buffer after the measurement is done + while True: + try: + self._buffer_q.get(block=False) + except queue.Empty: + break + + def get_data(self): + ret = b'' + count = 0 + while True: + try: + ret += self._buffer_q.get(timeout=0.001) # get_nowait sometimes skips a chunk for some reason + count += 1 + except queue.Empty: + break + return ret + + +class PPK2_MP(PPK2_API): + ''' + Multiprocessing variant of the object. The interface is the same as for the regular one except it spawns + a background process on start_measuring() + ''' + def __init__(self, port, buffer_max_size_seconds=10, buffer_chunk_seconds=0.1, **kwargs): + ''' + port - port where PPK2 is connected + buffer_max_size_seconds - how many seconds of data to keep in the buffer + buffer_chunk_seconds - how many seconds of data to put in the queue at once + **kwargs - keyword arguments to pass to the pySerial constructor + ''' + super().__init__(port, **kwargs) + + self._fetcher = None + self._quit_evt = threading.Event() + self._buffer_max_size_seconds = buffer_max_size_seconds + self._buffer_chunk_seconds = buffer_chunk_seconds + + def __del__(self): + """Destructor""" + PPK2_API.stop_measuring(self) + self._quit_evt.clear() + self._quit_evt = None + del self._quit_evt + if self._fetcher is not None: + self._fetcher.join() + self._fetcher = None + del self._fetcher + + def start_measuring(self): + # discard the data in the buffer + self.stop_measuring() + while self.get_data()!=b'': + pass + + PPK2_API.start_measuring(self) + self._quit_evt.clear() + if self._fetcher is not None: + return + + self._fetcher = PPK_Fetch(self, self._quit_evt, self._buffer_max_size_seconds, self._buffer_chunk_seconds) + self._fetcher.start() + + def stop_measuring(self): + PPK2_API.stop_measuring(self) + self.get_data() # flush the serial buffer (to prevent unicode error on next command) + self._quit_evt.set() + if self._fetcher is not None: + self._fetcher.join() # join() will block if the queue isn't empty + self._fetcher = None + + def get_data(self): + try: + return self._fetcher.get_data() + except (TypeError, AttributeError): + return b''