diff --git a/docs/release-notes.rst b/docs/release-notes.rst index 48a86a73..bbad7252 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -9,6 +9,8 @@ Release Date xx/xx/24 New Features ############ +- DMM drivers now have a new function to set NPLC (Number of Power Line Cycles) for the DMM. +- DMM drivers now have a new function to use the DMM's internal statistics function to take multiple measurements and return the mean, minimum and maximum values. Improvements ############ diff --git a/src/fixate/drivers/dmm/fluke_8846a.py b/src/fixate/drivers/dmm/fluke_8846a.py index 42b2243a..9677a7b3 100644 --- a/src/fixate/drivers/dmm/fluke_8846a.py +++ b/src/fixate/drivers/dmm/fluke_8846a.py @@ -42,6 +42,16 @@ def __init__(self, instrument, *args, **kwargs): "continuity": "CONF:CONTinuity", "diode": "CONF:DIODe", } + self._nplc_modes = [ + "resistance", + "fresistance", + "voltage_dc", + "current_dc", + "temperature", + "ftemperature", + ] + self._nplc_settings = [0.02, 0.2, 1, 10] + self._default_nplc = 10 # Default NPLC setting as per Fluke 8846A manual self._init_string = "" # Unchanging @property @@ -101,6 +111,31 @@ def measurements(self): with self.lock: return self._read_measurements() + def min_avg_max(self, samples=1, sample_time=1): + """ + automatically samples the DMM for a given number of samples and returns the min, max, and average values + :param samples: number of samples to take + :param sample_time: time to wait for the DMM to take the samples + return: min, avg, max values as floats in a dataclass + """ + + self._write(f"SAMP:COUN {samples}") + self._write("CALC:FUNC AVER") + self._write("CALC:STAT ON") + self._write("INIT") + time.sleep(sample_time) + min_ = self.instrument.query_ascii_values("CALC:AVER:MIN?")[0] + avg_ = self.instrument.query_ascii_values("CALC:AVER:AVER?")[0] + max_ = self.instrument.query_ascii_values("CALC:AVER:MAX?")[0] + + values = DMM.MeasurementStats(min=min_, avg=avg_, max=max_) + + # clean up + self._write("CALC:STAT OFF") + self._write("SAMP:COUN 1") + + return values + def reset(self): """ Checks for errors and then returns DMM to power up state @@ -308,3 +343,25 @@ def get_identity(self) -> str: (example: FLUKE, 45, 9080025, 2.0, D2.0) """ return self.instrument.query("*IDN?").strip() + + def set_nplc(self, nplc=None, reset=False): + if reset is True or nplc is None: + nplc = self._default_nplc + elif nplc not in self._nplc_settings: + raise ParameterError(f"Invalid NPLC setting {nplc}") + + if self._mode not in self._nplc_modes: + raise ParameterError(f"NPLC setting not available for mode {self._mode}") + + mode_str = f"{self._modes[self._mode]}" + + # Remove the CONF: from the start of the string + mode_str = mode_str.replace("CONF:", "") + + self._write(f"{mode_str}:NPLC {nplc}") # e.g. VOLT:DC:NPLC 10 + + def get_nplc(self): + mode_str = f"{self._modes[self._mode]}" + # Remove the CONF: from the start of the string + mode_str = mode_str.replace("CONF:", "") + return float(self.instrument.query(f"{mode_str}:NPLC?")) diff --git a/src/fixate/drivers/dmm/helper.py b/src/fixate/drivers/dmm/helper.py index f81bc88a..3e5699e6 100644 --- a/src/fixate/drivers/dmm/helper.py +++ b/src/fixate/drivers/dmm/helper.py @@ -1,3 +1,6 @@ +from dataclasses import dataclass + + class DMM: REGEX_ID = "DMM" is_connected = False @@ -101,3 +104,28 @@ def reset(self): def get_identity(self): raise NotImplementedError + + @dataclass + class MeasurementStats: + min: float + max: float + avg: float + + # context manager for setting NPLC + class _nplc_context_manager(object): + def __init__(self, dmm, nplc=None): + self.dmm = dmm + self.nplc = nplc + self.original_nplc = None + + def __enter__(self): + # store the original NPLC setting + self.original_nplc = self.dmm.get_nplc() + self.dmm.set_nplc(self.nplc) + + # return to default NPLC setting + def __exit__(self, exc_type, exc_val, exc_tb): + self.dmm.set_nplc(self.original_nplc) + + def nplc(self, nplc=None): + return self._nplc_context_manager(self, nplc) diff --git a/src/fixate/drivers/dmm/keithley_6500.py b/src/fixate/drivers/dmm/keithley_6500.py index b6c0e6db..9fe3813a 100644 --- a/src/fixate/drivers/dmm/keithley_6500.py +++ b/src/fixate/drivers/dmm/keithley_6500.py @@ -40,7 +40,17 @@ def __init__(self, instrument, *args, **kwargs): "continuity": "CONT", "diode": "DIOD", } - + # note: the keithley 6500 also supports changing NPLC for diode measurements, but this has been removed as the fluke does not support it. + self._nplc_modes = [ + "voltage_dc", + "current_dc", + "resistance", + "fresistance", + "temperature", + ] + # note: the keithley supports setting NPLC to any value between 0.0005 and 12 (with 50hz mains power) but for compatibility with the fluke, we only support the following values + self._nplc_settings = [0.02, 0.2, 1, 10] + self._nplc_default = 1 self._init_string = "" # Unchanging # Adapted for different DMM behaviour @@ -138,6 +148,33 @@ def measurements(self): with self.lock: return self._read_measurements() + def min_avg_max(self, samples=1, sample_time=1): + """ + automatically samples the DMM for a given number of samples and returns the min, max, and average values + :param samples: number of samples to take + :param sample_time: time to wait for the DMM to take the samples + return: min, avg, max values as floats in a dataclass + """ + + self._write(f'TRAC:MAKE "TempTable", {samples}') + self._write(f"SENS:COUNt {samples}") + + # we don't actually want the results, this is just to tell the DMM to start sampling + _ = self.instrument.query_ascii_values('READ? "TempTable"') + time.sleep(sample_time) + + avg_ = self.instrument.query_ascii_values('TRAC:STAT:AVER? "TempTable"')[0] + min_ = self.instrument.query_ascii_values('TRAC:STAT:MIN? "TempTable"')[0] + max_ = self.instrument.query_ascii_values('TRAC:STAT:MAX? "TempTable"')[0] + + # cleanup + self._write("SENS:COUNt 1") + self._write('TRAC:DEL "TempTable"') + + values = DMM.MeasurementStats(min=min_, avg=avg_, max=max_) + + return values + def reset(self): """ Checks for errors and then returns DMM to power up state @@ -348,3 +385,22 @@ def get_identity(self) -> str: (example: FLUKE, 45, 9080025, 2.0, D2.0) """ return self.instrument.query("*IDN?").strip() + + def set_nplc(self, nplc=None, reset=False): + if reset is True or nplc is None: + nplc = self._nplc_default + # note: keithley 6500 supports using "DEF" to reset to default NPLC setting + # however this sets the NPLC to 0.02 which is not the default setting + # the datasheet specifies 1 as the default (and this has been confirmed by resetting the dmm) + # so this behaviour is confusing. So we just manually set the default value to 1 + elif nplc not in self._nplc_settings: + raise ParameterError(f"Invalid NPLC setting {nplc}") + + if self._mode not in self._nplc_modes: + raise ParameterError(f"NPLC setting not available for mode {self._mode}") + + mode_str = f"{self._modes[self._mode]}" + self._write(f":SENS:{mode_str}:NPLC {nplc}") + + def get_nplc(self): + return float(self.instrument.query(f":SENS:{self._modes[self._mode]}:NPLC?")) diff --git a/test/drivers/test_fluke_8846A.py b/test/drivers/test_fluke_8846A.py index ceaacb72..98da3f76 100644 --- a/test/drivers/test_fluke_8846A.py +++ b/test/drivers/test_fluke_8846A.py @@ -316,6 +316,201 @@ def test_measurement_diode(funcgen, dmm, rm): assert meas == pytest.approx(TEST_DIODE, abs=TEST_DIODE_TOL) +@pytest.mark.parametrize( + "mode", + [ + ("voltage_dc"), + ("current_dc"), + ("resistance"), + ("fresistance"), + pytest.param( + "diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "period", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + ], +) +@pytest.mark.drivertest +def test_get_nplc(mode, dmm): + getattr(dmm, mode)() + dmm.set_nplc(reset=True) + query = dmm.get_nplc() + assert query == pytest.approx(10) + + +@pytest.mark.parametrize( + "mode", + [ + ("voltage_dc"), + ("current_dc"), + ("resistance"), + ("fresistance"), + pytest.param( + "diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "period", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + ], +) +@pytest.mark.drivertest +def test_set_nplc(mode, dmm): + getattr(dmm, mode)() + dmm.set_nplc(nplc=1) + query = dmm.get_nplc() + assert query == pytest.approx(1) + + dmm.set_nplc(nplc=None) # Set to default + query = dmm.get_nplc() + assert query == pytest.approx(10) + + # invalid nplc value + with pytest.raises(ParameterError): + dmm.set_nplc(nplc=999) + + +@pytest.mark.parametrize( + "mode", + [ + ("voltage_dc"), + ("current_dc"), + ("resistance"), + ("fresistance"), + pytest.param( + "diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "period", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + ], +) +@pytest.mark.drivertest +def test_nplc_context_manager(mode, dmm): + getattr(dmm, mode)() + + dmm.set_nplc(nplc=0.2) + with dmm.nplc(1): + query = dmm.get_nplc() + assert query == pytest.approx(1) + query = dmm.get_nplc() + assert query == pytest.approx(0.2) + + with pytest.raises(ZeroDivisionError): + with dmm.nplc(1): + _ = 1 / 0 # make sure exception is not swallowed + + +@pytest.mark.parametrize( + "mode, samples, nplc", + [ + ("voltage_ac", 10, None), + ("voltage_dc", 995, 0.02), + ("current_dc", 995, 0.02), + ("current_ac", 10, None), + ("period", 10, None), + ("frequency", 10, None), + ], +) +@pytest.mark.drivertest +def test_min_avg_max(mode, samples, nplc, dmm, rm, funcgen): + # dmm.voltage_dc() + getattr(dmm, mode)() + + # only set nplc when able (depends on mode) + if nplc: + dmm.set_nplc(nplc=nplc) + + v = 50e-3 + f = 50 + rm.mux.connectionMap("DMM_SIG") + funcgen.channel1.waveform.sin() + funcgen.channel1.vrms(v) + funcgen.channel1.frequency(f) + funcgen.channel1(True) + + time.sleep(0.5) + + values = dmm.min_avg_max(samples, 1.1) + min_val = values.min + avg_val = values.avg + max_val = values.max + + assert min_val < avg_val < max_val + + v = 100e-3 + f = 60 + funcgen.channel1.vrms(v) + funcgen.channel1.frequency(f) + time.sleep(0.5) + + values = dmm.min_avg_max(samples, 1.1) + min_val2 = values.min + avg_val2 = values.avg + max_val2 = values.max + + assert min_val2 < avg_val2 < max_val2 + + # check if values from the two runs are different + # We can only really do this for certain modes and the checks depend on the mode + if mode == "voltage_dc": + assert min_val2 < min_val + assert max_val2 > max_val + + if mode == "frequency": + assert min_val2 > min_val + assert max_val2 > max_val + + if mode == "period": + assert min_val2 < min_val + assert max_val2 < max_val + + @pytest.mark.drivertest def test_get_identity(dmm): iden = dmm.get_identity() diff --git a/test/drivers/test_keithley_6500.py b/test/drivers/test_keithley_6500.py index 476239c2..0481b2ea 100644 --- a/test/drivers/test_keithley_6500.py +++ b/test/drivers/test_keithley_6500.py @@ -324,6 +324,201 @@ def test_measurement_diode(funcgen, dmm, rm): assert meas == pytest.approx(TEST_DIODE, abs=TEST_DIODE_TOL) +@pytest.mark.parametrize( + "mode", + [ + ("voltage_dc"), + ("current_dc"), + ("resistance"), + ("fresistance"), + pytest.param( + "diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "period", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + ], +) +@pytest.mark.drivertest +def test_get_nplc(mode, dmm): + getattr(dmm, mode)() + dmm.set_nplc(reset=True) + query = dmm.get_nplc() + assert query == pytest.approx(1) + + +@pytest.mark.parametrize( + "mode", + [ + ("voltage_dc"), + ("current_dc"), + ("resistance"), + ("fresistance"), + pytest.param( + "diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "period", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + ], +) +@pytest.mark.drivertest +def test_set_nplc(mode, dmm): + getattr(dmm, mode)() + dmm.set_nplc(nplc=10) + query = dmm.get_nplc() + assert query == pytest.approx(10) + + dmm.set_nplc(nplc=None) # Set to default + query = dmm.get_nplc() + assert query == pytest.approx(1) + + # invalid nplc value + with pytest.raises(ParameterError): + dmm.set_nplc(nplc=999) + + +@pytest.mark.parametrize( + "mode", + [ + ("voltage_dc"), + ("current_dc"), + ("resistance"), + ("fresistance"), + pytest.param( + "diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "period", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + pytest.param( + "continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True) + ), + ], +) +@pytest.mark.drivertest +def test_nplc_context_manager(mode, dmm): + getattr(dmm, mode)() + + dmm.set_nplc(nplc=0.2) + with dmm.nplc(1): + query = dmm.get_nplc() + assert query == pytest.approx(1) + query = dmm.get_nplc() + assert query == pytest.approx(0.2) + + with pytest.raises(ZeroDivisionError): + with dmm.nplc(1): + _ = 1 / 0 # make sure exception is not swallowed + + +@pytest.mark.parametrize( + "mode, samples, nplc", + [ + ("voltage_ac", 10, None), + ("voltage_dc", 995, 0.02), + ("current_dc", 995, 0.02), + ("current_ac", 10, None), + ("period", 10, None), + ("frequency", 10, None), + ], +) +@pytest.mark.drivertest +def test_min_avg_max(mode, samples, nplc, dmm, rm, funcgen): + # dmm.voltage_dc() + getattr(dmm, mode)() + + # only set nplc when able (depends on mode) + if nplc: + dmm.set_nplc(nplc=nplc) + + v = 50e-3 + f = 50 + rm.mux.connectionMap("DMM_SIG") + funcgen.channel1.waveform.sin() + funcgen.channel1.vrms(v) + funcgen.channel1.frequency(f) + funcgen.channel1(True) + + time.sleep(0.5) + + values = dmm.min_avg_max(samples, 1.1) + min_val = values.min + avg_val = values.avg + max_val = values.max + + assert min_val < avg_val < max_val + + v = 100e-3 + f = 60 + funcgen.channel1.vrms(v) + funcgen.channel1.frequency(f) + time.sleep(0.5) + + values = dmm.min_avg_max(samples, 1.1) + min_val2 = values.min + avg_val2 = values.avg + max_val2 = values.max + + assert min_val2 < avg_val2 < max_val2 + + # check if values from the two runs are different + # We can only really do this for certain modes and the checks depend on the mode + if mode == "voltage_dc": + assert min_val2 < min_val + assert max_val2 > max_val + + if mode == "frequency": + assert min_val2 > min_val + assert max_val2 > max_val + + if mode == "period": + assert min_val2 < min_val + assert max_val2 < max_val + + @pytest.mark.drivertest def test_get_identity(dmm): iden = dmm.get_identity()