Skip to content

Commit a0cffd8

Browse files
committed
Initial commit
0 parents  commit a0cffd8

File tree

5 files changed

+338
-0
lines changed

5 files changed

+338
-0
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## Description
2+
The new Nordic Semiconductor's [Power Profiler Kit II (PPK 2)](https://www.nordicsemi.com/Software-and-tools/Development-Tools/Power-Profiler-Kit-2) is very useful for real time measurement of device power consumption. The official [nRF Connect Power Profiler tool](https://github.com/NordicSemiconductor/pc-nrfconnect-ppk) provides a friendly GUI with real-time data display. However there is no support for automated power monitoring. The puropose of this Python API is to enable automated power monitoring and data logging in Python applications.
3+
4+
![Power Profiler Kit II](https://github.com/IRNAS/ppk2-api-python/blob/main/images/power-profiler-kit-II.jpg)
5+
6+
## Features
7+
The main features of the PPK2 Python API (will) include:
8+
* All nRF Connect Power Profiler GUI functionality - In progress
9+
* Data logging to user selectable format - In progress
10+
* Cross-platform support
11+
12+
## Requirements
13+
Unlike the original Power Profiler Kit, the PPK2 uses Serial to communicate with the computer. No additional modules are required.
14+
15+
## Usage
16+
At this point in time the library provides the basic API with a basic example showing how to read data and toggle DUT power.
17+
18+
To enable power monitoring in Ampere mode implement the following sequence:
19+
```
20+
ppk2_test = PPK2_API("/dev/ttyACM3") # serial port will be different for you
21+
ppk2_test.get_modifiers()
22+
ppk2_test.use_ampere_meter() # set ampere meter mode
23+
ppk2_test.start_measuring() # start measuring
24+
25+
# read measured values in a for loop like this:
26+
for i in range(0, 1000):
27+
read_data = ppk2_test.get_data()
28+
if read_data != b'':
29+
ppk2_test.average_of_sampling_period(read_data)
30+
time.sleep(0.01)
31+
```

example.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
2+
"""
3+
Basic usage of PPK2 Python API.
4+
The basic ampere mode sequence is:
5+
1. read modifiers
6+
2. set ampere mode
7+
3. read stream of data
8+
"""
9+
import time
10+
from src.ppk2_api import PPK2_API
11+
12+
ppk2_test = PPK2_API("/dev/ttyACM3")
13+
ppk2_test.get_modifiers()
14+
ppk2_test.use_ampere_meter() # set ampere meter mode
15+
ppk2_test.toggle_DUT_power("OFF") # disable DUT power
16+
17+
ppk2_test.start_measuring() # start measuring
18+
# measurements are a constant stream of bytes
19+
# the number of measurements in one sampling period depends on the wait between serial reads
20+
# it appears the maximum number of bytes received is 1024
21+
# the sampling rate of the PPK2 is 100 samples per millisecond
22+
for i in range(0, 1000):
23+
read_data = ppk2_test.get_data()
24+
if read_data != b'':
25+
ppk2_test.average_of_sampling_period(read_data)
26+
time.sleep(0.01)
27+
28+
ppk2_test.toggle_DUT_power("ON")
29+
30+
ppk2_test.start_measuring()
31+
for i in range(0, 1000):
32+
read_data = ppk2_test.get_data()
33+
if read_data != b'':
34+
ppk2_test.average_of_sampling_period(read_data)
35+
time.sleep(0.01)
36+
37+
ppk2_test.stop_measuring()

images/power-profiler-kit-II.jpg

7.48 MB
Loading
8.29 KB
Binary file not shown.

src/ppk2_api.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
"""
2+
This python API is written for use with the Nordic Semiconductor's Power Profiler Kit II (PPK 2).
3+
The PPK2 uses Serial communication.
4+
The official nRF Connect Power Profiler was used as a reference: https://github.com/NordicSemiconductor/pc-nrfconnect-ppk
5+
"""
6+
7+
import serial
8+
import time
9+
import struct
10+
11+
12+
class PPK2_Command():
13+
"""Serial command opcodes"""
14+
NO_OP = 0x00
15+
TRIGGER_SET = 0x01
16+
AVG_NUM_SET = 0x02 # no-firmware
17+
TRIGGER_WINDOW_SET = 0x03
18+
TRIGGER_INTERVAL_SET = 0x04
19+
TRIGGER_SINGLE_SET = 0x05
20+
AVERAGE_START = 0x06
21+
AVERAGE_STOP = 0x07
22+
RANGE_SET = 0x08
23+
LCD_SET = 0x09
24+
TRIGGER_STOP = 0x0a
25+
DEVICE_RUNNING_SET = 0x0c
26+
REGULATOR_SET = 0x0d
27+
SWITCH_POINT_DOWN = 0x0e
28+
SWITCH_POINT_UP = 0x0f
29+
TRIGGER_EXT_TOGGLE = 0x11
30+
SET_POWER_MODE = 0x11
31+
RES_USER_SET = 0x12
32+
SPIKE_FILTERING_ON = 0x15
33+
SPIKE_FILTERING_OFF = 0x16
34+
GET_META_DATA = 0x19
35+
RESET = 0x20
36+
SET_USER_GAINS = 0x25
37+
38+
class PPK2_Modes():
39+
"""PPK2 measurement modes"""
40+
AMPERE_MODE = "AMPERE_MODE"
41+
SOURCE_MODE = "SOURCE_MODE"
42+
43+
class PPK2_API():
44+
def __init__(self, port):
45+
46+
self.ser = serial.Serial(port)
47+
self.ser.baudrate = 9600
48+
49+
self.modifiers = {
50+
"Calibrated": None,
51+
"R": {"0": None, "1": None, "2": None, "3": None, "4": None},
52+
"GS": {"0": None, "1": None, "2": None, "3": None, "4": None},
53+
"GI": {"0": None, "1": None, "2": None, "3": None, "4": None},
54+
"O": {"0": None, "1": None, "2": None, "3": None, "4": None},
55+
"S": {"0": None, "1": None, "2": None, "3": None, "4": None},
56+
"I": {"0": None, "1": None, "2": None, "3": None, "4": None},
57+
"UG": {"0": None, "1": None, "2": None, "3": None, "4": None},
58+
"HW": None,
59+
"IA": None
60+
}
61+
62+
self.vdd_low = 800
63+
self.vdd_high = 5000
64+
65+
self.current_vdd = 0
66+
67+
self.adc_mult = 1.8 / 163840
68+
69+
self.MEAS_ADC = self._generate_mask(14, 0)
70+
self.MEAS_RANGE = self._generate_mask(3, 14)
71+
self.MEAS_LOGIC = self._generate_mask(8, 24)
72+
73+
self.prev_rolling_avg = None
74+
self.prev_rolling_avg4 = None
75+
self.prev_range = None
76+
77+
self.mode = None
78+
79+
# adc measurement buffer remainder and len of remainder
80+
self.remainder = {"sequence": b'', "len": 0}
81+
82+
def _pack_struct(self, cmd_tuple):
83+
"""Returns packed struct"""
84+
return struct.pack("B" * len(cmd_tuple), *cmd_tuple)
85+
86+
def _write_serial(self, cmd_tuple):
87+
"""Writes cmd bytes to serial"""
88+
cmd_packed = self._pack_struct(cmd_tuple)
89+
self.ser.write(cmd_packed)
90+
91+
def _twos_comp(self, val):
92+
"""Compute the 2's complement of int32 value"""
93+
if (val & (1 << (32 - 1))) != 0:
94+
val = val - (1 << 32) # compute negative value
95+
return val
96+
97+
def _convert_source_voltage(self, mV):
98+
"""Convert input voltage to device command"""
99+
# minimal possible mV is 800
100+
if mV < self.vdd_low:
101+
mV = self.vdd_low
102+
103+
# maximal possible mV is 5000
104+
if mV > self.vdd_high:
105+
mV = self.vdd_high
106+
107+
offset = 32
108+
# get difference to baseline (the baseline is 800mV but the initial offset is 32)
109+
diff_to_baseline = mV - self.vdd_low + offset
110+
base_b_1 = 3
111+
base_b_2 = 0 # is actually 32 - compensated with above offset
112+
113+
# get the number of times we have to increase the first byte of the command
114+
ratio = int(diff_to_baseline / 256)
115+
remainder = diff_to_baseline % 256 # get the remainder for byte 2
116+
117+
set_b_1 = base_b_1 + ratio
118+
set_b_2 = base_b_2 + remainder
119+
120+
return set_b_1, set_b_2
121+
122+
def _read_metadata(self):
123+
"""Read metadata"""
124+
# try to get metadata from device
125+
for _ in range(0, 5):
126+
# it appears the second reading is the metadata
127+
read = self.ser.read(self.ser.in_waiting)
128+
time.sleep(0.1)
129+
130+
if read != b'' and "END" in read.decode("utf-8"):
131+
return read.decode("utf-8")
132+
133+
def _parse_metadata(self, metadata):
134+
"""Parse metadata and store it to modifiers"""
135+
data_split = [row.split(": ") for row in metadata.split("\n")]
136+
137+
for key in self.modifiers.keys():
138+
for data_pair in data_split:
139+
if key == data_pair[0]:
140+
self.modifiers[key] = data_pair[1]
141+
for ind in range(0, 5):
142+
if key+str(ind) == data_pair[0]:
143+
self.modifiers[key][str(ind)] = float(data_pair[1])
144+
145+
def _generate_mask(self, bits, pos):
146+
pos = pos
147+
mask = ((2**bits-1) << pos)
148+
mask = self._twos_comp(mask)
149+
return {"mask": mask, "pos": pos}
150+
151+
def _get_masked_value(self, value, meas):
152+
masked_value = (value & meas["mask"]) >> meas["pos"]
153+
return masked_value
154+
155+
def _handle_raw_data(self, adc_value):
156+
"""Convert raw value to analog value"""
157+
current_measurement_range = min(self._get_masked_value(
158+
adc_value, self.MEAS_RANGE), 5) # 5 is the number of parameters
159+
adc_result = self._get_masked_value(adc_value, self.MEAS_ADC) * 4
160+
bits = self._get_masked_value(adc_value, self.MEAS_LOGIC)
161+
analog_value = self.get_adc_result(
162+
current_measurement_range, adc_result) * 10**6
163+
164+
return analog_value
165+
166+
def get_data(self):
167+
"""Return readings of one sampling period"""
168+
sampling_data = self.ser.read(self.ser.in_waiting)
169+
return sampling_data
170+
171+
def get_modifiers(self):
172+
"""Gets and sets modifiers from device memory"""
173+
self._write_serial((PPK2_Command.GET_META_DATA, ))
174+
metadata = self._read_metadata()
175+
self._parse_metadata(metadata)
176+
177+
def start_measuring(self):
178+
"""Start continous measurement"""
179+
self._write_serial((PPK2_Command.AVERAGE_START, ))
180+
181+
def stop_measuring(self):
182+
"""Stop continous measurement"""
183+
self._write_serial((PPK2_Command.AVERAGE_STOP, ))
184+
185+
def set_source_voltage(self, mV):
186+
"""Inits device - based on observation only REGULATOR_SET is the command.
187+
The other two values correspond to the voltage level.
188+
189+
800mV is the lowest setting - [3,32] - the values then increase linearly
190+
"""
191+
b_1, b_2 = self._convert_source_voltage(mV)
192+
self._write_serial((PPK2_Command.REGULATOR_SET, b_1, b_2))
193+
#self.current_vdd = mV
194+
195+
def toggle_DUT_power(self, state):
196+
"""Toggle DUT power based on parameter"""
197+
if state == "ON":
198+
self._write_serial((PPK2_Command.DEVICE_RUNNING_SET, PPK2_Command.TRIGGER_SET)) # 12,1
199+
200+
if state == "OFF":
201+
self._write_serial((PPK2_Command.DEVICE_RUNNING_SET, PPK2_Command.NO_OP)) # 12,0
202+
203+
def use_ampere_meter(self):
204+
"""Configure device to use ampere meter"""
205+
self.mode = PPK2_Modes.AMPERE_MODE
206+
self._write_serial((PPK2_Command.SET_POWER_MODE, PPK2_Command.TRIGGER_SET)) # 17,1
207+
208+
def use_source_meter(self):
209+
"""Configure device to use source meter"""
210+
self.mode = PPK2_Modes.SOURCE_MODE
211+
self._write_serial((PPK2_Command.SET_POWER_MODE, PPK2_Command.AVG_NUM_SET)) # 17,2
212+
213+
def get_adc_result(self, current_range, adc_value):
214+
"""Get result of adc conversion"""
215+
current_range = str(current_range)
216+
result_without_gain = (adc_value - self.modifiers["O"][current_range]) * (
217+
self.adc_mult / self.modifiers["R"][current_range])
218+
219+
adc = self.modifiers["UG"][current_range] * (
220+
result_without_gain *
221+
(self.modifiers["GS"][current_range] *
222+
result_without_gain + self.modifiers["GI"][current_range])
223+
# this part is used only in source meter mode
224+
+ (self.modifiers["S"][current_range] +
225+
(self.current_vdd / 1000) + self.modifiers["I"][current_range])
226+
)
227+
228+
self.rolling_avg = adc
229+
self.rolling_avg4 = adc
230+
231+
return adc
232+
233+
def _digital_to_analog(self, adc_value):
234+
"""Convert discrete value to analog value"""
235+
return int.from_bytes(adc_value, byteorder="little", signed=False) # convert reading to analog value
236+
237+
def average_of_sampling_period(self, buf):
238+
"""
239+
Calculates the average value of one sampling period.
240+
The number of sampled values depends on the delay between serial reads.
241+
See example for more info.
242+
"""
243+
244+
sample_size = 4 # one analog value is 4 bytes in size
245+
offset = self.remainder["len"]
246+
measurement_avg = 0
247+
num_samples = 0
248+
249+
first_reading = (self.remainder["sequence"] + buf[0:sample_size-offset])[:4]
250+
adc_val = self._digital_to_analog(first_reading)
251+
measurement_avg += self._handle_raw_data(adc_val)
252+
num_samples += 1
253+
254+
offset = sample_size - offset
255+
256+
while offset <= len(buf) - sample_size:
257+
next_val = buf[offset:offset + sample_size]
258+
offset += sample_size
259+
adc_val = self._digital_to_analog(next_val)
260+
261+
measurement_avg += self._handle_raw_data(adc_val)
262+
num_samples += 1
263+
264+
print("Avg of {} samples: {} μA".format(
265+
num_samples, measurement_avg/num_samples))
266+
267+
self.remainder["sequence"] = buf[offset:len(buf)]
268+
self.remainder["len"] = len(buf)-offset
269+
270+
return measurement_avg/num_samples

0 commit comments

Comments
 (0)