Skip to content

Commit 5a2f3e8

Browse files
authored
Try #426:
2 parents 4738a15 + b8910f9 commit 5a2f3e8

8 files changed

Lines changed: 257 additions & 60 deletions

File tree

hitl/run.sh

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
set -eux
1212

1313
# Set up python for testing
14-
python3 -m venv --system-site-packages py
15-
. py/bin/activate
14+
python3 -m venv --system-site-packages py-venv
15+
. py-venv/bin/activate
1616

1717
# Install Miniconf utilities for configuring stabilizer.
18+
python3 -m pip install -e py/
1819
python3 -m pip install git+https://github.com/quartiq/miniconf#subdirectory=py/miniconf-mqtt
1920
python3 -m pip install gmqtt
2021

@@ -36,3 +37,6 @@ python3 -m miniconf dt/sinara/dual-iir/04-91-62-d9-7e-5f afe/0='"G1"' iir_ch/0/0
3637

3738
# Test the ADC/DACs connected via loopback.
3839
python3 hitl/loopback.py dt/sinara/dual-iir/04-91-62-d9-7e-5f
40+
41+
# Test the livestream capabilities
42+
python3 hitl/streaming.py dt/sinara/dual-iir/04-91-62-d9-7e-5f

hitl/streaming.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/python3
2+
"""
3+
Author: Vertigo Designs, Ryan Summers
4+
5+
Description: Implements HITL testing of Stabilizer data livestream capabilities.
6+
"""
7+
import asyncio
8+
import sys
9+
import argparse
10+
import logging
11+
import socket
12+
import time
13+
14+
from miniconf import Miniconf
15+
from stabilizer.stream import StabilizerStream
16+
17+
# The duration to receive frames for.
18+
STREAM_TEST_DURATION_SECS = 5.0
19+
20+
# The minimum efficiency of the stream in frame transfer to pass testing. Represented as
21+
# (received_frames / transmitted_frames).
22+
MIN_STREAM_EFFICIENCY = 0.95
23+
24+
def _get_ip(broker):
25+
""" Get the IP of the local device.
26+
27+
Args:
28+
broker: The broker IP of the test. Used to select an interface to get the IP of.
29+
30+
Returns:
31+
The IP as an array of integers.
32+
"""
33+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
34+
try:
35+
sock.connect((broker, 1883))
36+
address = sock.getsockname()[0]
37+
finally:
38+
sock.close()
39+
40+
return list(map(int, address.split('.')))
41+
42+
43+
def sequence_delta(previous_sequence, next_sequence):
44+
""" Check the number of items between two sequence numbers. """
45+
if previous_sequence is None:
46+
return 0
47+
48+
delta = next_sequence - (previous_sequence + 1)
49+
return delta & 0xFFFFFFFF
50+
51+
52+
def main():
53+
""" Main program entry point. """
54+
parser = argparse.ArgumentParser(description='Loopback tests for Stabilizer HITL testing',)
55+
parser.add_argument('prefix', type=str,
56+
help='The MQTT topic prefix of the target')
57+
parser.add_argument('--broker', '-b', default='mqtt', type=str,
58+
help='The MQTT broker address')
59+
parser.add_argument('--port', '-p', default=2000, type=int,
60+
help='The UDP port to use for streaming')
61+
62+
args = parser.parse_args()
63+
64+
async def test():
65+
""" The actual testing being completed. """
66+
local_ip = _get_ip(args.broker)
67+
interface = await Miniconf.create(args.prefix, args.broker)
68+
stream = StabilizerStream(args.port, timeout=0.5)
69+
70+
# Configure the stream
71+
print(f'Configuring stream to target {".".join(map(str, local_ip))}:{args.port}')
72+
print('')
73+
await interface.command('stream_target', {'ip': local_ip, 'port': args.port}, retain=False)
74+
await interface.command('telemetry_period', 10, retain=False)
75+
76+
# Verify frame reception
77+
print('Testing stream reception')
78+
print('')
79+
last_sequence = None
80+
81+
# Sample frames over a set time period and verify that no drops are encountered.
82+
stop = time.time() + STREAM_TEST_DURATION_SECS
83+
dropped_frames = 0
84+
total_frames = 0
85+
86+
while time.time() < stop:
87+
for (seqnum, _data) in stream.read_frame():
88+
num_dropped = sequence_delta(last_sequence, seqnum)
89+
total_frames += 1 + num_dropped
90+
91+
if num_dropped:
92+
dropped_frames += num_dropped
93+
logging.warning('Frame drop detected: 0x%08X -> 0x%08X (%d frames)',
94+
last_sequence, seqnum, num_dropped)
95+
96+
last_sequence = seqnum
97+
98+
assert total_frames, 'Stream did not receive any frames'
99+
stream_efficiency = 1.0 - (dropped_frames / total_frames)
100+
101+
print(f'Stream Reception Rate: {stream_efficiency * 100:.2f} %')
102+
print(f'Received {total_frames} frames')
103+
print(f'Lost {dropped_frames} frames')
104+
105+
assert stream_efficiency > MIN_STREAM_EFFICIENCY, \
106+
f'Stream dropped too many packets. Reception rate: {stream_efficiency * 100:.2f} %'
107+
108+
# Disable the stream.
109+
print('Closing stream')
110+
print('')
111+
await interface.command('stream_target', {'ip': [0, 0, 0, 0], 'port': 0}, retain=False)
112+
stream.clear()
113+
114+
print('Verifying no further data is received')
115+
try:
116+
for _ in stream.read_frame():
117+
raise Exception('Unexpected data encountered on stream')
118+
except socket.timeout:
119+
pass
120+
print('PASS')
121+
122+
123+
loop = asyncio.get_event_loop()
124+
sys.exit(loop.run_until_complete(test()))
125+
126+
127+
if __name__ == '__main__':
128+
main()

py/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/*.egg-info/
2+
__pycache__/

py/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Stabilizer Python Utilities
2+
3+
This directory contains common Python utilities for Stabilizer, such as livestream data receivers.
4+
5+
To install this module locally (in editable mode):
6+
```
7+
python -m pip install -e .
8+
```

py/setup.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from setuptools import setup
2+
3+
setup(name='stabilizer',
4+
version='0.1',
5+
description='Stabilizer Utilities',
6+
author='QUARTIQ GmbH',
7+
license='MIT')

py/stabilizer/__init__.py

Whitespace-only changes.

py/stabilizer/stream.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/usr/bin/python3
2+
"""
3+
Author: Vertigo Designs, Ryan Summers
4+
5+
Description: Provides a means of accessing Stabilizer livestream data.
6+
"""
7+
import socket
8+
import time
9+
import logging
10+
import struct
11+
12+
class StabilizerStream:
13+
""" Provides access to Stabilizer's livestreamed data. """
14+
15+
# The magic header half-word at the start of each packet.
16+
MAGIC_HEADER = 0x057B
17+
18+
# The struct format of the header.
19+
HEADER_FORMAT = '<HBBI'
20+
21+
# All supported formats by this reception script.
22+
#
23+
# The items in this dict are functions that will be provided the sample batch size and will
24+
# return the struct deserialization code to unpack a single batch.
25+
FORMAT = {
26+
1: lambda batch_size: f'<{batch_size}H{batch_size}H{batch_size}H{batch_size}H'
27+
}
28+
29+
def __init__(self, port, timeout=None):
30+
""" Initialize the stream.
31+
32+
Args:
33+
port: The UDP port to receive the stream from.
34+
timeout: The timeout to set on the UDP socket.
35+
"""
36+
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
37+
self.socket.bind(("", port))
38+
self.total_bytes = 0
39+
40+
if timeout is not None:
41+
self.socket.settimeout(timeout)
42+
43+
44+
def clear(self, duration=1):
45+
""" Clear the socket RX buffer by reading all available data.
46+
47+
Args:
48+
duration: The maximum duration in seconds to read data for.
49+
"""
50+
time.sleep(duration)
51+
52+
try:
53+
while self.socket.recv(4096):
54+
pass
55+
except socket.timeout:
56+
pass
57+
58+
59+
def get_rx_bytes(self):
60+
""" Get the number of bytes read from the stream. """
61+
return self.total_bytes
62+
63+
64+
def read_frame(self):
65+
""" Read a single frame from the stream.
66+
67+
Returns:
68+
Yields the (seqnum, data) of the batches available in the frame.
69+
"""
70+
buf = self.socket.recv(4096)
71+
self.total_bytes += len(buf)
72+
73+
# Attempt to parse a block from the buffer.
74+
if len(buf) < struct.calcsize(self.HEADER_FORMAT):
75+
return
76+
77+
# Parse out the packet header
78+
magic, format_id, batch_size, sequence_number = struct.unpack_from(self.HEADER_FORMAT, buf)
79+
buf = buf[struct.calcsize(self.HEADER_FORMAT):]
80+
81+
if magic != self.MAGIC_HEADER:
82+
logging.warning('Encountered bad magic header: %s', hex(magic))
83+
return
84+
85+
frame_format = self.FORMAT[format_id](batch_size)
86+
87+
batch_count = int(len(buf) / struct.calcsize(frame_format))
88+
89+
for offset in range(batch_count):
90+
data = struct.unpack_from(frame_format, buf)
91+
buf = buf[struct.calcsize(frame_format):]
92+
yield (sequence_number + offset, data)

scripts/stream_throughput.py

Lines changed: 14 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,11 @@
55
Description: Provides a mechanism for measuring Stabilizer stream data throughput.
66
"""
77
import argparse
8-
import socket
9-
import collections
10-
import struct
11-
import time
128
import logging
9+
import sys
10+
import time
1311

14-
# Representation of a single data batch transmitted by Stabilizer.
15-
Packet = collections.namedtuple('Packet', ['index', 'data'])
16-
17-
# The magic header half-word at the start of each packet.
18-
MAGIC_HEADER = 0x057B
19-
20-
# The struct format of the header.
21-
HEADER_FORMAT = '<HBBI'
22-
23-
# All supported formats by this reception script.
24-
#
25-
# The items in this dict are functions that will be provided the sample batch size and will return
26-
# the struct deserialization code to unpack a single batch.
27-
FORMAT = {
28-
1: lambda batch_size: f'<{batch_size}H{batch_size}H{batch_size}H{batch_size}H'
29-
}
30-
31-
def parse_packet(buf):
32-
""" Attempt to parse packets from the received buffer. """
33-
# Attempt to parse a block from the buffer.
34-
if len(buf) < struct.calcsize(HEADER_FORMAT):
35-
return
36-
37-
# Parse out the packet header
38-
magic, format_id, batch_size, sequence_number = struct.unpack_from(HEADER_FORMAT, buf)
39-
buf = buf[struct.calcsize(HEADER_FORMAT):]
40-
41-
if magic != MAGIC_HEADER:
42-
logging.warning('Encountered bad magic header: %s', hex(magic))
43-
return
44-
45-
frame_format = FORMAT[format_id](batch_size)
46-
47-
batch_count = int(len(buf) / struct.calcsize(frame_format))
48-
49-
for offset in range(batch_count):
50-
data = struct.unpack_from(frame_format, buf)
51-
buf = buf[struct.calcsize(frame_format):]
52-
yield Packet(sequence_number + offset, data)
53-
54-
12+
from stabilizer.stream import StabilizerStream
5513

5614
class Timer:
5715
""" A basic timer for measuring elapsed time periods. """
@@ -104,13 +62,11 @@ def sequence_delta(previous_sequence, next_sequence):
10462
def main():
10563
""" Main program. """
10664
parser = argparse.ArgumentParser(description='Measure Stabilizer livestream quality')
107-
parser.add_argument('--port', type=int, default=1111, help='The port that stabilizer is streaming to')
65+
parser.add_argument('--port', type=int, default=2000,
66+
help='The port that stabilizer is streaming to')
10867

10968
args = parser.parse_args()
11069

111-
connection = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
112-
connection.bind(("", args.port))
113-
11470
logging.basicConfig(level=logging.INFO,
11571
format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s')
11672

@@ -122,23 +78,22 @@ def main():
12278

12379
timer = Timer()
12480

81+
stream = StabilizerStream(args.port)
82+
12583
while True:
12684
# Receive any data over UDP and parse it.
127-
data = connection.recv(4096)
128-
if data and not timer.is_started():
129-
timer.start()
85+
for (seqnum, _) in stream.read_frame():
86+
if not timer.is_started():
87+
timer.start()
13088

131-
# Handle any received packets.
132-
total_bytes += len(data)
133-
for packet in parse_packet(data):
13489
# Handle any dropped packets.
135-
drop_count += sequence_delta(last_index, packet.index)
136-
last_index = packet.index
90+
drop_count += sequence_delta(last_index, seqnum)
91+
last_index = seqnum
13792
good_blocks += 1
13893

13994
# Report the throughput periodically.
14095
if timer.is_triggered():
141-
drate = total_bytes * 8 / 1e6 / timer.elapsed()
96+
drate = stream.get_rx_bytes() * 8 / 1e6 / timer.elapsed()
14297

14398
print(f'''
14499
Data Rate: {drate:.3f} Mbps
@@ -148,6 +103,7 @@ def main():
148103
Metadata: {total_bytes / 1e6:.3f} MB in {timer.elapsed():.2f} s
149104
----
150105
''')
106+
sys.stdout.flush()
151107
timer.arm()
152108

153109

0 commit comments

Comments
 (0)