Skip to content

Commit 19e39b4

Browse files
Merge pull request #216 from Overlord360/DMM-NPLC-command
DMM Drivers - NPLC and Min/Max/Avg support
2 parents 8bdef2a + 3dfa2a1 commit 19e39b4

File tree

6 files changed

+534
-1
lines changed

6 files changed

+534
-1
lines changed

docs/release-notes.rst

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Release Date xx/xx/24
99

1010
New Features
1111
############
12+
- DMM drivers now have a new function to set NPLC (Number of Power Line Cycles) for the DMM.
13+
- 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.
1214

1315
Improvements
1416
############

src/fixate/drivers/dmm/fluke_8846a.py

+57
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ def __init__(self, instrument, *args, **kwargs):
4242
"continuity": "CONF:CONTinuity",
4343
"diode": "CONF:DIODe",
4444
}
45+
self._nplc_modes = [
46+
"resistance",
47+
"fresistance",
48+
"voltage_dc",
49+
"current_dc",
50+
"temperature",
51+
"ftemperature",
52+
]
53+
self._nplc_settings = [0.02, 0.2, 1, 10]
54+
self._default_nplc = 10 # Default NPLC setting as per Fluke 8846A manual
4555
self._init_string = "" # Unchanging
4656

4757
@property
@@ -101,6 +111,31 @@ def measurements(self):
101111
with self.lock:
102112
return self._read_measurements()
103113

114+
def min_avg_max(self, samples=1, sample_time=1):
115+
"""
116+
automatically samples the DMM for a given number of samples and returns the min, max, and average values
117+
:param samples: number of samples to take
118+
:param sample_time: time to wait for the DMM to take the samples
119+
return: min, avg, max values as floats in a dataclass
120+
"""
121+
122+
self._write(f"SAMP:COUN {samples}")
123+
self._write("CALC:FUNC AVER")
124+
self._write("CALC:STAT ON")
125+
self._write("INIT")
126+
time.sleep(sample_time)
127+
min_ = self.instrument.query_ascii_values("CALC:AVER:MIN?")[0]
128+
avg_ = self.instrument.query_ascii_values("CALC:AVER:AVER?")[0]
129+
max_ = self.instrument.query_ascii_values("CALC:AVER:MAX?")[0]
130+
131+
values = DMM.MeasurementStats(min=min_, avg=avg_, max=max_)
132+
133+
# clean up
134+
self._write("CALC:STAT OFF")
135+
self._write("SAMP:COUN 1")
136+
137+
return values
138+
104139
def reset(self):
105140
"""
106141
Checks for errors and then returns DMM to power up state
@@ -308,3 +343,25 @@ def get_identity(self) -> str:
308343
(example: FLUKE, 45, 9080025, 2.0, D2.0)
309344
"""
310345
return self.instrument.query("*IDN?").strip()
346+
347+
def set_nplc(self, nplc=None, reset=False):
348+
if reset is True or nplc is None:
349+
nplc = self._default_nplc
350+
elif nplc not in self._nplc_settings:
351+
raise ParameterError(f"Invalid NPLC setting {nplc}")
352+
353+
if self._mode not in self._nplc_modes:
354+
raise ParameterError(f"NPLC setting not available for mode {self._mode}")
355+
356+
mode_str = f"{self._modes[self._mode]}"
357+
358+
# Remove the CONF: from the start of the string
359+
mode_str = mode_str.replace("CONF:", "")
360+
361+
self._write(f"{mode_str}:NPLC {nplc}") # e.g. VOLT:DC:NPLC 10
362+
363+
def get_nplc(self):
364+
mode_str = f"{self._modes[self._mode]}"
365+
# Remove the CONF: from the start of the string
366+
mode_str = mode_str.replace("CONF:", "")
367+
return float(self.instrument.query(f"{mode_str}:NPLC?"))

src/fixate/drivers/dmm/helper.py

+28
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from dataclasses import dataclass
2+
3+
14
class DMM:
25
REGEX_ID = "DMM"
36
is_connected = False
@@ -101,3 +104,28 @@ def reset(self):
101104

102105
def get_identity(self):
103106
raise NotImplementedError
107+
108+
@dataclass
109+
class MeasurementStats:
110+
min: float
111+
max: float
112+
avg: float
113+
114+
# context manager for setting NPLC
115+
class _nplc_context_manager(object):
116+
def __init__(self, dmm, nplc=None):
117+
self.dmm = dmm
118+
self.nplc = nplc
119+
self.original_nplc = None
120+
121+
def __enter__(self):
122+
# store the original NPLC setting
123+
self.original_nplc = self.dmm.get_nplc()
124+
self.dmm.set_nplc(self.nplc)
125+
126+
# return to default NPLC setting
127+
def __exit__(self, exc_type, exc_val, exc_tb):
128+
self.dmm.set_nplc(self.original_nplc)
129+
130+
def nplc(self, nplc=None):
131+
return self._nplc_context_manager(self, nplc)

src/fixate/drivers/dmm/keithley_6500.py

+57-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,17 @@ def __init__(self, instrument, *args, **kwargs):
4040
"continuity": "CONT",
4141
"diode": "DIOD",
4242
}
43-
43+
# note: the keithley 6500 also supports changing NPLC for diode measurements, but this has been removed as the fluke does not support it.
44+
self._nplc_modes = [
45+
"voltage_dc",
46+
"current_dc",
47+
"resistance",
48+
"fresistance",
49+
"temperature",
50+
]
51+
# 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
52+
self._nplc_settings = [0.02, 0.2, 1, 10]
53+
self._nplc_default = 1
4454
self._init_string = "" # Unchanging
4555

4656
# Adapted for different DMM behaviour
@@ -138,6 +148,33 @@ def measurements(self):
138148
with self.lock:
139149
return self._read_measurements()
140150

151+
def min_avg_max(self, samples=1, sample_time=1):
152+
"""
153+
automatically samples the DMM for a given number of samples and returns the min, max, and average values
154+
:param samples: number of samples to take
155+
:param sample_time: time to wait for the DMM to take the samples
156+
return: min, avg, max values as floats in a dataclass
157+
"""
158+
159+
self._write(f'TRAC:MAKE "TempTable", {samples}')
160+
self._write(f"SENS:COUNt {samples}")
161+
162+
# we don't actually want the results, this is just to tell the DMM to start sampling
163+
_ = self.instrument.query_ascii_values('READ? "TempTable"')
164+
time.sleep(sample_time)
165+
166+
avg_ = self.instrument.query_ascii_values('TRAC:STAT:AVER? "TempTable"')[0]
167+
min_ = self.instrument.query_ascii_values('TRAC:STAT:MIN? "TempTable"')[0]
168+
max_ = self.instrument.query_ascii_values('TRAC:STAT:MAX? "TempTable"')[0]
169+
170+
# cleanup
171+
self._write("SENS:COUNt 1")
172+
self._write('TRAC:DEL "TempTable"')
173+
174+
values = DMM.MeasurementStats(min=min_, avg=avg_, max=max_)
175+
176+
return values
177+
141178
def reset(self):
142179
"""
143180
Checks for errors and then returns DMM to power up state
@@ -348,3 +385,22 @@ def get_identity(self) -> str:
348385
(example: FLUKE, 45, 9080025, 2.0, D2.0)
349386
"""
350387
return self.instrument.query("*IDN?").strip()
388+
389+
def set_nplc(self, nplc=None, reset=False):
390+
if reset is True or nplc is None:
391+
nplc = self._nplc_default
392+
# note: keithley 6500 supports using "DEF" to reset to default NPLC setting
393+
# however this sets the NPLC to 0.02 which is not the default setting
394+
# the datasheet specifies 1 as the default (and this has been confirmed by resetting the dmm)
395+
# so this behaviour is confusing. So we just manually set the default value to 1
396+
elif nplc not in self._nplc_settings:
397+
raise ParameterError(f"Invalid NPLC setting {nplc}")
398+
399+
if self._mode not in self._nplc_modes:
400+
raise ParameterError(f"NPLC setting not available for mode {self._mode}")
401+
402+
mode_str = f"{self._modes[self._mode]}"
403+
self._write(f":SENS:{mode_str}:NPLC {nplc}")
404+
405+
def get_nplc(self):
406+
return float(self.instrument.query(f":SENS:{self._modes[self._mode]}:NPLC?"))

test/drivers/test_fluke_8846A.py

+195
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,201 @@ def test_measurement_diode(funcgen, dmm, rm):
316316
assert meas == pytest.approx(TEST_DIODE, abs=TEST_DIODE_TOL)
317317

318318

319+
@pytest.mark.parametrize(
320+
"mode",
321+
[
322+
("voltage_dc"),
323+
("current_dc"),
324+
("resistance"),
325+
("fresistance"),
326+
pytest.param(
327+
"diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
328+
),
329+
pytest.param(
330+
"voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
331+
),
332+
pytest.param(
333+
"current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
334+
),
335+
pytest.param(
336+
"period", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
337+
),
338+
pytest.param(
339+
"frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
340+
),
341+
pytest.param(
342+
"capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
343+
),
344+
pytest.param(
345+
"continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
346+
),
347+
],
348+
)
349+
@pytest.mark.drivertest
350+
def test_get_nplc(mode, dmm):
351+
getattr(dmm, mode)()
352+
dmm.set_nplc(reset=True)
353+
query = dmm.get_nplc()
354+
assert query == pytest.approx(10)
355+
356+
357+
@pytest.mark.parametrize(
358+
"mode",
359+
[
360+
("voltage_dc"),
361+
("current_dc"),
362+
("resistance"),
363+
("fresistance"),
364+
pytest.param(
365+
"diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
366+
),
367+
pytest.param(
368+
"voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
369+
),
370+
pytest.param(
371+
"current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
372+
),
373+
pytest.param(
374+
"period", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
375+
),
376+
pytest.param(
377+
"frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
378+
),
379+
pytest.param(
380+
"capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
381+
),
382+
pytest.param(
383+
"continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
384+
),
385+
],
386+
)
387+
@pytest.mark.drivertest
388+
def test_set_nplc(mode, dmm):
389+
getattr(dmm, mode)()
390+
dmm.set_nplc(nplc=1)
391+
query = dmm.get_nplc()
392+
assert query == pytest.approx(1)
393+
394+
dmm.set_nplc(nplc=None) # Set to default
395+
query = dmm.get_nplc()
396+
assert query == pytest.approx(10)
397+
398+
# invalid nplc value
399+
with pytest.raises(ParameterError):
400+
dmm.set_nplc(nplc=999)
401+
402+
403+
@pytest.mark.parametrize(
404+
"mode",
405+
[
406+
("voltage_dc"),
407+
("current_dc"),
408+
("resistance"),
409+
("fresistance"),
410+
pytest.param(
411+
"diode", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
412+
),
413+
pytest.param(
414+
"voltage_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
415+
),
416+
pytest.param(
417+
"current_ac", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
418+
),
419+
pytest.param(
420+
"period", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
421+
),
422+
pytest.param(
423+
"frequency", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
424+
),
425+
pytest.param(
426+
"capacitance", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
427+
),
428+
pytest.param(
429+
"continuity", marks=pytest.mark.xfail(raises=ParameterError, strict=True)
430+
),
431+
],
432+
)
433+
@pytest.mark.drivertest
434+
def test_nplc_context_manager(mode, dmm):
435+
getattr(dmm, mode)()
436+
437+
dmm.set_nplc(nplc=0.2)
438+
with dmm.nplc(1):
439+
query = dmm.get_nplc()
440+
assert query == pytest.approx(1)
441+
query = dmm.get_nplc()
442+
assert query == pytest.approx(0.2)
443+
444+
with pytest.raises(ZeroDivisionError):
445+
with dmm.nplc(1):
446+
_ = 1 / 0 # make sure exception is not swallowed
447+
448+
449+
@pytest.mark.parametrize(
450+
"mode, samples, nplc",
451+
[
452+
("voltage_ac", 10, None),
453+
("voltage_dc", 995, 0.02),
454+
("current_dc", 995, 0.02),
455+
("current_ac", 10, None),
456+
("period", 10, None),
457+
("frequency", 10, None),
458+
],
459+
)
460+
@pytest.mark.drivertest
461+
def test_min_avg_max(mode, samples, nplc, dmm, rm, funcgen):
462+
# dmm.voltage_dc()
463+
getattr(dmm, mode)()
464+
465+
# only set nplc when able (depends on mode)
466+
if nplc:
467+
dmm.set_nplc(nplc=nplc)
468+
469+
v = 50e-3
470+
f = 50
471+
rm.mux.connectionMap("DMM_SIG")
472+
funcgen.channel1.waveform.sin()
473+
funcgen.channel1.vrms(v)
474+
funcgen.channel1.frequency(f)
475+
funcgen.channel1(True)
476+
477+
time.sleep(0.5)
478+
479+
values = dmm.min_avg_max(samples, 1.1)
480+
min_val = values.min
481+
avg_val = values.avg
482+
max_val = values.max
483+
484+
assert min_val < avg_val < max_val
485+
486+
v = 100e-3
487+
f = 60
488+
funcgen.channel1.vrms(v)
489+
funcgen.channel1.frequency(f)
490+
time.sleep(0.5)
491+
492+
values = dmm.min_avg_max(samples, 1.1)
493+
min_val2 = values.min
494+
avg_val2 = values.avg
495+
max_val2 = values.max
496+
497+
assert min_val2 < avg_val2 < max_val2
498+
499+
# check if values from the two runs are different
500+
# We can only really do this for certain modes and the checks depend on the mode
501+
if mode == "voltage_dc":
502+
assert min_val2 < min_val
503+
assert max_val2 > max_val
504+
505+
if mode == "frequency":
506+
assert min_val2 > min_val
507+
assert max_val2 > max_val
508+
509+
if mode == "period":
510+
assert min_val2 < min_val
511+
assert max_val2 < max_val
512+
513+
319514
@pytest.mark.drivertest
320515
def test_get_identity(dmm):
321516
iden = dmm.get_identity()

0 commit comments

Comments
 (0)