Skip to content

Commit 7e31c37

Browse files
committed
Refactor multimeter
- Multimeter related functionality (measure voltage, resistance, and capacitance) moved from class ScienceLab in sciencelab.py to new Multimeter class in multimeter.py. - Implemeted Multimeter as subclass of Oscilloscope. - Added calibration method for stray capacitance. After calibration, PSLab can measure capacitance as low as 10 pF. - Added doctrings. - Added multimeter.py to lint include list. - Added tests in tests/test_multimeter.py - Make frequency counter return 0 for low frequencies.
1 parent b8df71a commit 7e31c37

11 files changed

+312
-232
lines changed

PSL/logic_analyzer.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,12 @@ def measure_frequency(
9191
self._channel_one_map = channel
9292
t = self.capture(1, 2, modes=["sixteen rising"], timeout=timeout)[0]
9393
self._channel_one_map = tmp
94-
period = (t[1] - t[0]) * 1e-6 / 16
95-
frequency = period ** -1
94+
95+
try:
96+
period = (t[1] - t[0]) * 1e-6 / 16
97+
frequency = period ** -1
98+
except IndexError:
99+
frequency = 0
96100

97101
if frequency >= 1e7:
98102
frequency = self._get_high_frequency(channel)
@@ -660,7 +664,7 @@ def get_states(self) -> Dict[str, bool]:
660664
}
661665

662666
def count_pulses(
663-
self, channel: str, interval: float = 1, block: bool = True
667+
self, channel: str = "FRQ", interval: float = 1, block: bool = True
664668
) -> Union[int, None]:
665669
"""Count pulses on a digital input.
666670
@@ -669,8 +673,8 @@ def count_pulses(
669673
670674
Parameters
671675
----------
672-
channel : {"LA1", "LA2", "LA3", "LA4"}
673-
Digital input on which to count pulses.
676+
channel : {"LA1", "LA2", "LA3", "LA4", "FRQ"}, optional
677+
Digital input on which to count pulses. The default value is "FRQ".
674678
interval : float, optional
675679
Time in seconds during which to count pulses. The default value is
676680
1 second.

PSL/multimeter.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""The PSLab's multimeter can measure voltage, resistance, and capacitance."""
2+
import time
3+
from typing import Tuple
4+
5+
import numpy as np
6+
from scipy.optimize import curve_fit
7+
8+
import PSL.commands_proto as CP
9+
from PSL.achan import GAIN_VALUES, INPUT_RANGES
10+
from PSL.oscilloscope import Oscilloscope
11+
from PSL.packet_handler import Handler
12+
13+
MICROSECONDS = 1e-6
14+
15+
16+
class Multimeter(Oscilloscope):
17+
"""Measure voltage, resistance and capacitance.
18+
19+
Parameters
20+
----------
21+
device : Handler
22+
Serial interface for communicating with the PSLab device. If not
23+
provided, a new one will be created.
24+
"""
25+
26+
CURRENTS = [5.5e-4, 5.5e-7, 5.5e-6, 5.5e-5]
27+
CURRENTS_RANGES = [1, 2, 3, 0] # Smallest first,
28+
RC_RESISTANCE = 1e4
29+
CAPACITOR_CHARGED_VOLTAGE = 0.9 * max(INPUT_RANGES["CAP"])
30+
CAPACITOR_DISCHARGED_VOLTAGE = 0.01 * max(INPUT_RANGES["CAP"])
31+
32+
def __init__(self, device: Handler = None):
33+
self._stray_capacitance = 5e-11
34+
super().__init__(device)
35+
36+
def measure_resistance(self) -> float:
37+
"""Measure the resistance of a resistor connected between RES and GND.
38+
39+
Returns
40+
-------
41+
resistance : float
42+
Resistance in ohm.
43+
"""
44+
voltage = self.measure_voltage("RES")
45+
resolution = max(INPUT_RANGES["RES"]) / (
46+
2 ** self._channels["RES"].resolution - 1
47+
)
48+
49+
if voltage >= max(INPUT_RANGES["RES"]) - resolution:
50+
return np.inf
51+
52+
pull_up_resistance = 5.1e3
53+
current = (INPUT_RANGES["RES"][1] - voltage) / pull_up_resistance
54+
55+
return voltage / current
56+
57+
def measure_voltage(self, channel: str = "VOL") -> float:
58+
"""Measure the voltage on the selected channel.
59+
60+
Parameters
61+
----------
62+
channel : {"CH1", "CH2", "CH3", "MIC", "CAP", "RES", "VOL"}, optional
63+
The name of the analog input on which to measure the voltage. The
64+
default channel is VOL.
65+
66+
Returns
67+
-------
68+
voltage : float
69+
Voltage in volts.
70+
"""
71+
self._voltmeter_autorange(channel)
72+
return self._measure_voltage(channel)
73+
74+
def _measure_voltage(self, channel: str) -> float:
75+
self._channels[channel].resolution = 12
76+
scale = self._channels[channel].scale
77+
chosa = self._channels[channel].chosa
78+
self._device.send_byte(CP.ADC)
79+
self._device.send_byte(CP.GET_VOLTAGE_SUMMED)
80+
self._device.send_byte(chosa)
81+
raw_voltage_sum = self._device.get_int() # Sum of 16 samples.
82+
self._device.get_ack()
83+
raw_voltage_mean = round(raw_voltage_sum / 16)
84+
voltage = scale(raw_voltage_mean)
85+
86+
return voltage
87+
88+
def _voltmeter_autorange(self, channel: str) -> float:
89+
if channel in ("CH1", "CH2"):
90+
voltage = self._measure_voltage(channel)
91+
92+
for gain in GAIN_VALUES[::-1]:
93+
rng = max(INPUT_RANGES[channel]) / gain
94+
if abs(voltage) < rng:
95+
break
96+
97+
self._set_gain(channel, gain)
98+
99+
return rng
100+
else:
101+
return max(INPUT_RANGES[channel])
102+
103+
def calibrate_capacitance(self):
104+
"""Calibrate stray capacitance.
105+
106+
Correctly calibrated stray capacitance is important when measuring
107+
small capacitors (picofarad range).
108+
109+
Stray capacitace should be recalibrated if external wiring is connected
110+
to the CAP pin.
111+
"""
112+
for charge_time in np.unique(np.int16(np.logspace(2, 3))):
113+
voltage, capacitance = self._measure_capacitance(1, 0, charge_time)
114+
if voltage >= 3:
115+
break
116+
self._stray_capacitance += capacitance
117+
118+
def measure_capacitance(self) -> float:
119+
"""Measure the capacitance of a capacitor connected between CAP and GND.
120+
121+
Returns
122+
-------
123+
capacitance : float
124+
Capacitance in farad.
125+
"""
126+
previous_capacitance = 0
127+
128+
for current_range in self.CURRENTS_RANGES:
129+
for i in (100, 200, 400, 800):
130+
voltage, capacitance = self._measure_capacitance(current_range, 0, i)
131+
132+
if voltage >= self.CAPACITOR_CHARGED_VOLTAGE:
133+
return previous_capacitance
134+
else:
135+
previous_capacitance = capacitance
136+
137+
# Capacitor too big, use alternative method.
138+
return self._measure_rc_capacitance()
139+
140+
def _set_cap(self, state, charge_time):
141+
"""Set CAP HIGH or LOW."""
142+
self._device.send_byte(CP.ADC)
143+
self._device.send_byte(CP.SET_CAP)
144+
self._device.send_byte(state)
145+
self._device.send_int(charge_time)
146+
self._device.get_ack()
147+
148+
def _discharge_capacitor(
149+
self, discharge_time: int = 50000, timeout: float = 1
150+
) -> float:
151+
start_time = time.time()
152+
voltage = self.measure_voltage("CAP")
153+
154+
while voltage > self.CAPACITOR_DISCHARGED_VOLTAGE:
155+
self._set_cap(0, discharge_time)
156+
voltage = self.measure_voltage("CAP")
157+
if time.time() - start_time > timeout:
158+
break
159+
160+
return voltage
161+
162+
def _measure_capacitance(
163+
self, current_range: int, trim: int, charge_time: int
164+
) -> Tuple[float, float]:
165+
self._discharge_capacitor()
166+
self._channels["CAP"].resolution = 12
167+
self._device.send_byte(CP.COMMON)
168+
self._device.send_byte(CP.GET_CAPACITANCE)
169+
self._device.send_byte(current_range)
170+
171+
if trim < 0:
172+
self._device.send_byte(int(31 - abs(trim) / 2) | 32)
173+
else:
174+
self._device.send_byte(int(trim / 2))
175+
176+
self._device.send_int(charge_time)
177+
time.sleep(charge_time * MICROSECONDS)
178+
raw_voltage = self._device.get_int()
179+
voltage = self._channels["CAP"].scale(raw_voltage)
180+
self._device.get_ack()
181+
charge_current = self.CURRENTS[current_range] * (100 + trim) / 100
182+
183+
if voltage:
184+
capacitance = (
185+
charge_current * charge_time * MICROSECONDS / voltage
186+
- self._stray_capacitance
187+
)
188+
else:
189+
capacitance = 0
190+
191+
return voltage, capacitance
192+
193+
def _measure_rc_capacitance(self) -> float:
194+
"""Measure the capacitance by discharge through a 10K resistor."""
195+
(x,) = self.capture("CAP", CP.MAX_SAMPLES, 10, block=False)
196+
x *= MICROSECONDS
197+
self._set_cap(1, 50000) # charge
198+
self._set_cap(0, 50000) # discharge
199+
(y,) = self.fetch_data()
200+
201+
if y.max() >= self.CAPACITOR_CHARGED_VOLTAGE:
202+
discharge_start = np.where(y >= self.CAPACITOR_CHARGED_VOLTAGE)[0][-1]
203+
else:
204+
discharge_start = np.where(y == y.max())[0][-1]
205+
206+
x = x[discharge_start:]
207+
y = y[discharge_start:]
208+
209+
# CAP floats for a brief period of time (~500 µs) between being set
210+
# HIGH until it is set LOW. This data is not useful and should be
211+
# discarded. When CAP is set LOW the voltage declines sharply, which
212+
# manifests as a negative peak in the time derivative.
213+
dydx = np.diff(y) / np.diff(x)
214+
cap_low = np.where(dydx == dydx.min())[0][0]
215+
x = x[cap_low:]
216+
y = y[cap_low:]
217+
218+
# Discard data after the voltage reaches zero (improves fit).
219+
if 0 in y:
220+
v_zero = np.where(y == 0)[0][0]
221+
x = x[:v_zero]
222+
y = y[:v_zero]
223+
224+
# Remove time offset.
225+
x -= x[0]
226+
227+
def discharging_capacitor_voltage(
228+
x: np.ndarray, v_init: float, rc_time_constant: float
229+
) -> np.ndarray:
230+
return v_init * np.exp(-x / rc_time_constant)
231+
232+
# Solve discharging_capacitor_voltage for rc_time_constant.
233+
rc_time_constant_guess = (-x[1:] / np.log(y[1:] / y[0])).mean()
234+
guess = [y[0], rc_time_constant_guess]
235+
popt, _ = curve_fit(discharging_capacitor_voltage, x, y, guess)
236+
rc_time_constant = popt[1]
237+
rc_capacitance = rc_time_constant / self.RC_RESISTANCE
238+
239+
return rc_capacitance

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy