|
| 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