Skip to content

Commit 711d783

Browse files
authored
Analog threshold improvements (#141)
* analog threshold improvements - can now provide a upper and lower threshold - raise an error if a rising or falling event is provided without a threshold - raise errors if threshold(s) not provided correctly - add ability to change threshold(s) after task has started * Analog input can now accept multiple triggers * automatically log trigger thresholds/bounds * move schmitt trigger to separate device file * change name back to Analog_threshold and make sure Analog_input() is backwards compatible * make rotary_encoder backwards compatible * add threshold:set type:subtype * updated running_wheel.py example - uses new syntax for adding trigger - demonstrates ability to have multiple triggers * task and run_start subtypes for threshold * renamed Analog_threshold class to AnalogTrigger * add information about what analog input the trigger is attached to * updated running wheel example
1 parent f8b5bdf commit 711d783

File tree

8 files changed

+214
-32
lines changed

8 files changed

+214
-32
lines changed

devices/rotary_encoder.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def __init__(
1414
falling_event=None,
1515
bytes_per_sample=2,
1616
reverse=False,
17+
triggers=None,
1718
):
1819
assert output in ("velocity", "position"), "ouput argument must be 'velocity' or 'position'."
1920
assert bytes_per_sample in (2, 4), "bytes_per_sample must be 2 or 4"
@@ -28,6 +29,7 @@ def __init__(
2829
self.position = 0
2930
self.velocity = 0
3031
self.sampling_rate = sampling_rate
32+
3133
Analog_input.__init__(
3234
self,
3335
None,
@@ -37,6 +39,7 @@ def __init__(
3739
rising_event,
3840
falling_event,
3941
data_type={2: "h", 4: "i"}[bytes_per_sample],
42+
triggers=triggers,
4043
)
4144

4245
def read_sample(self):

devices/schmitt_trigger.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from pyControl.hardware import IO_object, assign_ID, interrupt_queue
2+
import pyControl.framework as fw
3+
import pyControl.state_machine as sm
4+
5+
6+
class Crossing:
7+
above = "above"
8+
below = "below"
9+
none = "none"
10+
11+
12+
class SchmittTrigger(IO_object):
13+
"""
14+
Generates framework events when an analog signal goes above an upper threshold and/or below a lower threshold.
15+
The rising event is triggered when signal > upper bound, falling event is triggered when signal < lower bound.
16+
17+
This trigger implements hysteresis, which is a technique to prevent rapid oscillations or "bouncing" of events:
18+
- Hysteresis creates a "dead zone" between the upper and lower thresholds
19+
- Once a rising event is triggered (when signal crosses above the upper bound),
20+
it cannot be triggered again until the signal has fallen below the lower bound
21+
- Similarly, once a falling event is triggered (when signal crosses below the lower bound),
22+
it cannot be triggered again until the signal has risen above the upper bound
23+
24+
This behavior is particularly useful for noisy signals that might otherwise rapidly cross a single threshold
25+
multiple times, generating unwanted repeated events.
26+
"""
27+
28+
def __init__(self, bounds, rising_event=None, falling_event=None):
29+
if rising_event is None and falling_event is None:
30+
raise ValueError("Either rising_event or falling_event or both must be specified.")
31+
self.rising_event = rising_event
32+
self.falling_event = falling_event
33+
self.bounds = bounds
34+
self.timestamp = 0
35+
assign_ID(self)
36+
37+
def run_start(self, attached_to):
38+
self.attached_to = attached_to
39+
self.set_bounds(self.bounds, run_start=True)
40+
41+
def set_bounds(self, threshold, run_start=False):
42+
if isinstance(threshold, tuple):
43+
threshold_requirements_str = "The threshold must be a tuple of two integers (lower_bound, upper_bound) where lower_bound <= upper_bound."
44+
if len(threshold) != 2:
45+
raise ValueError("{} is not a valid threshold. {}".format(threshold, threshold_requirements_str))
46+
lower, upper = threshold
47+
if not upper >= lower:
48+
raise ValueError(
49+
"{} is not a valid threshold because the lower bound {} is greater than the upper bound {}. {}".format(
50+
threshold, lower, upper, threshold_requirements_str
51+
)
52+
)
53+
self.upper_threshold = upper
54+
self.lower_threshold = lower
55+
else:
56+
raise ValueError("{} is not a valid threshold. {}".format(threshold, threshold_requirements_str))
57+
self.reset_crossing = True
58+
59+
content = {"bounds": (self.lower_threshold, self.upper_threshold), "attached_to": self.attached_to}
60+
if self.rising_event is not None:
61+
content["rising_event"] = self.rising_event
62+
if self.falling_event is not None:
63+
content["falling_event"] = self.falling_event
64+
fw.data_output_queue.put(
65+
fw.Datatuple(
66+
fw.current_time,
67+
fw.THRSH_TYP,
68+
"s" if run_start else "t",
69+
str(content),
70+
)
71+
)
72+
73+
def _initialise(self):
74+
# Set event codes for rising and falling events.
75+
self.rising_event_ID = sm.events[self.rising_event] if self.rising_event in sm.events else False
76+
self.falling_event_ID = sm.events[self.falling_event] if self.falling_event in sm.events else False
77+
self.threshold_active = self.rising_event_ID or self.falling_event_ID
78+
79+
def _process_interrupt(self):
80+
# Put event generated by threshold crossing in event queue.
81+
if self.was_above:
82+
fw.event_queue.put(fw.Datatuple(self.timestamp, fw.EVENT_TYP, "i", self.rising_event_ID))
83+
else:
84+
fw.event_queue.put(fw.Datatuple(self.timestamp, fw.EVENT_TYP, "i", self.falling_event_ID))
85+
86+
@micropython.native
87+
def check(self, sample):
88+
if self.reset_crossing:
89+
# this gets run when the first sample is taken and whenever the threshold is changed
90+
self.reset_crossing = False
91+
self.was_above = sample > self.upper_threshold
92+
self.was_below = sample < self.lower_threshold
93+
self.last_crossing = Crossing.none
94+
return
95+
is_above_threshold = sample > self.upper_threshold
96+
is_below_threshold = sample < self.lower_threshold
97+
98+
if is_above_threshold and not self.was_above and self.last_crossing != Crossing.above:
99+
self.timestamp = fw.current_time
100+
self.last_crossing = Crossing.above
101+
if self.rising_event_ID:
102+
interrupt_queue.put(self.ID)
103+
elif is_below_threshold and not self.was_below and self.last_crossing != Crossing.below:
104+
self.timestamp = fw.current_time
105+
self.last_crossing = Crossing.below
106+
if self.falling_event_ID:
107+
interrupt_queue.put(self.ID)
108+
109+
self.was_above, self.was_below = is_above_threshold, is_below_threshold

source/communication/data_logger.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def write_info_line(self, subtype, content, time=0):
7373
self.data_file.write(self.tsv_row_str("info", time, subtype, content))
7474

7575
def tsv_row_str(self, rtype, time, subtype="", content=""):
76-
time_str = f"{time/1000:.3f}" if isinstance(time, int) else time
76+
time_str = f"{time / 1000:.3f}" if isinstance(time, int) else time
7777
return f"{time_str}\t{rtype}\t{subtype}\t{content}\n"
7878

7979
def copy_task_file(self, data_dir, tasks_dir, dir_name="task_files"):
@@ -140,6 +140,8 @@ def data_to_string(self, new_data, prettify=False, max_len=60):
140140
var_str += f'\t\t\t"{var_name}": {var_value}\n'
141141
var_str += "\t\t\t}"
142142
data_string += self.tsv_row_str("variable", time, nd.subtype, content=var_str)
143+
elif nd.type == MsgType.THRSH: # Threshold
144+
data_string += self.tsv_row_str("threshold", time, nd.subtype, content=nd.content)
143145
elif nd.type == MsgType.WARNG: # Warning
144146
data_string += self.tsv_row_str("warning", time, content=nd.content)
145147
elif nd.type in (MsgType.ERROR, MsgType.STOPF): # Error or stop framework.

source/communication/message.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class MsgType(Enum):
1616
ERROR = b"!!" # Error
1717
STOPF = b"X" # Stop framework
1818
ANLOG = b"A" # Analog
19+
THRSH = b"T" # Threshold
1920

2021
@classmethod
2122
def from_byte(cls, byte_value):
@@ -51,5 +52,10 @@ def get_subtype(self, subtype_char):
5152
"t": "task",
5253
"a": "api",
5354
"u": "user",
55+
"s": "trigger",
56+
},
57+
MsgType.THRSH: {
58+
"s": "run_start",
59+
"t": "task",
5460
},
5561
}[self][subtype_char]

source/communication/pycboard.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ def process_data(self):
492492
self.timestamp = msg_timestamp
493493
if msg_type in (MsgType.EVENT, MsgType.STATE):
494494
content = int(content_bytes.decode()) # Event/state ID.
495-
elif msg_type in (MsgType.PRINT, MsgType.WARNG):
495+
elif msg_type in (MsgType.PRINT, MsgType.WARNG, MsgType.THRSH):
496496
content = content_bytes.decode() # Print or error string.
497497
elif msg_type == MsgType.VARBL:
498498
content = content_bytes.decode() # JSON string

source/pyControl/framework.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class pyControlError(BaseException): # Exception for pyControl errors.
2424
VARBL_TYP = b"V" # Variable change : (time, VARBL_TYP, [g]et/user_[s]et/[a]pi_set/[p]rint/s[t]art/[e]nd, json_str)
2525
WARNG_TYP = b"!" # Warning : (time, WARNG_TYP, "", print_string)
2626
STOPF_TYP = b"X" # Stop framework : (time, STOPF_TYP, "", "")
27+
THRSH_TYP = b"T" # Threshold : (time, THRSH_TYP, [s]et)
2728

2829
# Event_queue -----------------------------------------------------------------
2930

source/pyControl/hardware.py

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -235,25 +235,36 @@ class Analog_input(IO_object):
235235
# streams data to computer. Optionally can generate framework events when voltage
236236
# goes above / below specified value theshold.
237237

238-
def __init__(self, pin, name, sampling_rate, threshold=None, rising_event=None, falling_event=None, data_type="H"):
239-
if rising_event or falling_event:
240-
self.threshold = Analog_threshold(threshold, rising_event, falling_event)
241-
else:
242-
self.threshold = False
238+
def __init__(
239+
self,
240+
pin,
241+
name,
242+
sampling_rate,
243+
threshold=None,
244+
rising_event=None,
245+
falling_event=None,
246+
data_type="H",
247+
triggers=None,
248+
):
249+
self.triggers = triggers if triggers is not None else []
250+
251+
if threshold is not None: # For backward compatibility
252+
self.triggers.append(AnalogTrigger(threshold, rising_event, falling_event))
253+
243254
self.timer = pyb.Timer(available_timers.pop())
244255
if pin: # pin argument can be None when Analog_input subclassed.
245256
self.ADC = pyb.ADC(pin)
246257
self.read_sample = self.ADC.read
247258
self.name = name
248-
self.Analog_channel = Analog_channel(name, sampling_rate, data_type)
259+
self.channel = Analog_channel(name, sampling_rate, data_type)
249260
assign_ID(self)
250261

251262
def _run_start(self):
252263
# Start sampling timer, initialise threshold, aquire first sample.
253-
self.timer.init(freq=self.Analog_channel.sampling_rate)
264+
self.timer.init(freq=self.channel.sampling_rate)
254265
self.timer.callback(self._timer_ISR)
255-
if self.threshold:
256-
self.threshold.run_start(self.read_sample())
266+
for trigger in self.triggers:
267+
trigger.run_start(self.name)
257268
self._timer_ISR(0)
258269

259270
def _run_stop(self):
@@ -263,9 +274,10 @@ def _run_stop(self):
263274
def _timer_ISR(self, t):
264275
# Read a sample to the buffer, update write index.
265276
sample = self.read_sample()
266-
self.Analog_channel.put(sample)
267-
if self.threshold:
268-
self.threshold.check(sample)
277+
self.channel.put(sample)
278+
if self.triggers:
279+
for trigger in self.triggers:
280+
trigger.check(sample)
269281

270282
def record(self): # For backward compatibility.
271283
pass
@@ -286,15 +298,21 @@ class Analog_channel(IO_object):
286298
# data array bytes (variable)
287299

288300
def __init__(self, name, sampling_rate, data_type, plot=True):
289-
assert data_type in ("b", "B", "h", "H", "i", "I"), "Invalid data_type."
290-
assert not any([name == io.name for io in IO_dict.values() if isinstance(io, Analog_channel)]), (
291-
"Analog signals must have unique names."
292-
)
301+
if data_type not in ("b", "B", "h", "H", "i", "I"):
302+
raise ValueError("Invalid data_type.")
303+
if any([name == io.name for io in IO_dict.values() if isinstance(io, Analog_channel)]):
304+
raise ValueError(
305+
"Analog signals must have unique names.{} {}".format(
306+
name, [io.name for io in IO_dict.values() if isinstance(io, Analog_channel)]
307+
)
308+
)
309+
293310
self.name = name
294311
assign_ID(self)
295312
self.sampling_rate = sampling_rate
296313
self.data_type = data_type
297314
self.plot = plot
315+
298316
self.bytes_per_sample = {"b": 1, "B": 1, "h": 2, "H": 2, "i": 4, "I": 4}[data_type]
299317
self.buffer_size = max(4, min(256 // self.bytes_per_sample, sampling_rate // 10))
300318
self.buffers = (array(data_type, [0] * self.buffer_size), array(data_type, [0] * self.buffer_size))
@@ -344,16 +362,15 @@ def send_buffer(self, run_stop=False):
344362
fw.usb_serial.send(self.buffers[buffer_n])
345363

346364

347-
class Analog_threshold(IO_object):
348-
# Generates framework events when an analog signal goes above or below specified threshold.
365+
class AnalogTrigger(IO_object):
366+
# Generates framework events when an analog signal goes above or below specified threshold value.
349367

350-
def __init__(self, threshold=None, rising_event=None, falling_event=None):
351-
assert isinstance(threshold, int), (
352-
"Integer threshold must be specified if rising or falling events are defined."
353-
)
354-
self.threshold = threshold
368+
def __init__(self, threshold, rising_event=None, falling_event=None):
369+
if rising_event is None and falling_event is None:
370+
raise ValueError("Either rising_event or falling_event or both must be specified.")
355371
self.rising_event = rising_event
356372
self.falling_event = falling_event
373+
self.threshold = threshold
357374
self.timestamp = 0
358375
self.crossing_direction = False
359376
assign_ID(self)
@@ -364,8 +381,9 @@ def _initialise(self):
364381
self.falling_event_ID = sm.events[self.falling_event] if self.falling_event in sm.events else False
365382
self.threshold_active = self.rising_event_ID or self.falling_event_ID
366383

367-
def run_start(self, sample):
368-
self.above_threshold = sample > self.threshold
384+
def run_start(self, attached_to):
385+
self.attached_to = attached_to
386+
self.set_threshold(self.threshold, run_start=True)
369387

370388
def _process_interrupt(self):
371389
# Put event generated by threshold crossing in event queue.
@@ -376,14 +394,40 @@ def _process_interrupt(self):
376394

377395
@micropython.native
378396
def check(self, sample):
397+
if self.reset_above_threshold:
398+
# this gets run when the first sample is taken and whenever the threshold is changed
399+
self.reset_above_threshold = False
400+
self.above_threshold = sample > self.threshold
401+
return
379402
new_above_threshold = sample > self.threshold
380403
if new_above_threshold != self.above_threshold: # Threshold crossing.
381404
self.above_threshold = new_above_threshold
382405
if (self.above_threshold and self.rising_event_ID) or (not self.above_threshold and self.falling_event_ID):
383406
self.timestamp = fw.current_time
384407
self.crossing_direction = self.above_threshold
408+
385409
interrupt_queue.put(self.ID)
386410

411+
def set_threshold(self, threshold, run_start=False):
412+
if not isinstance(threshold, int):
413+
raise ValueError(f"Threshold must be an integer, got {type(threshold).__name__}.")
414+
self.threshold = threshold
415+
self.reset_above_threshold = True
416+
417+
content = {"value": self.threshold, "attached_to": self.attached_to}
418+
if self.rising_event is not None:
419+
content["rising_event"] = self.rising_event
420+
if self.falling_event is not None:
421+
content["falling_event"] = self.falling_event
422+
fw.data_output_queue.put(
423+
fw.Datatuple(
424+
fw.current_time,
425+
fw.THRSH_TYP,
426+
"s" if run_start else "t",
427+
str(content),
428+
)
429+
)
430+
387431

388432
# Digital Output --------------------------------------------------------------
389433

@@ -509,4 +553,3 @@ def _timer_callback(self):
509553
fw.data_output_queue.put(fw.Datatuple(fw.current_time, fw.EVENT_TYP, "s", self.event_ID))
510554
self.state = not self.state
511555
self.sync_pin.value(self.state)
512-

0 commit comments

Comments
 (0)