Mechanical Display

April 30, 2025 FCSC 2025 #hardware #communication bus

Link to the challenge

This challenge provides us with a VCD file which represents the control signal applied to a little servo for a short span of time.

We thus wrote a script that extracts the timestamps of the rising and falling edges, used the provided linear relation between the control signal’s pulse width and the angle of the servo and tried to map it to characters. But that was not sufficient as the angles were slightly shifted. So we corrected with the min and max values of the angles as seen on our first implementation and got this beautiful image of the movement of the arm over time.

To find the values of each character we considered a minimum duration during which the arm had to be in vicinity of the letter to prevent noise.

We thus obtained this beautiful plot of the movement of the arm over time

arm angle

import re

import matplotlib.pyplot as plt

characters = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'F', 'S', '{', '}', '_']


def parse_vcd(file_path: str) -> list[tuple[int, int]]:
    with open(file_path, 'r') as f:
        content = f.read()

    timescale_match = re.search(r'\$timescale\s+(\d+)\s+(\w+)', content)
    if timescale_match:
        timescale_value = int(timescale_match.group(1))
        timescale_unit = timescale_match.group(2)
        print(f'Timescale: {timescale_value} {timescale_unit}')

    changes = []
    for line in content.split('\n'):
        if line.startswith('#'):
            parts = line.split()
            if len(parts) >= 2:
                time = int(parts[0][1:])  # Remove '#' and convert to int
                if len(parts) == 2 and (parts[1].endswith('0!') or parts[1].endswith('1!')):
                    value = int(parts[1][0])
                    changes.append((time, value))

    return changes


# Calculate pulse widths
def calculate_pulse_widths(changes: list[tuple[int, int]]) -> list[int]:
    pulse_widths = []
    pulse_start = None

    for time, value in changes:
        if value == 1:  # Rising edge
            pulse_start = time
        elif value == 0 and pulse_start is not None:  # Falling edge
            pulse_width = time - pulse_start
            pulse_widths.append(pulse_width)
            pulse_start = None

    return pulse_widths


def pulse_width_to_angle(pulse_width: int, timescale: int = 10) -> float:
    pulse_ms = pulse_width * timescale / 1000

    # Linear mapping from pulse width to angle based on servo specifications from datasheet
    # 0.6ms -> -90 degrees
    # 1.5ms -> 0 degrees
    # 2.4ms -> +90 degrees
    angle = -90 + (pulse_ms - 0.6) * (180 / (2.4 - 0.6))

    return angle


def plot_angles_over_time(
    angles: list[float],
    stable_segments: None | list[tuple[float, float, float, float]] = None,
) -> None:
    plt.figure(figsize=(20, 10))

    plt.plot(angles, color='gray', alpha=0.7, linewidth=1, label='Raw angles')

    observed_min_angle = -95
    observed_max_angle = 90.2
    observed_range = observed_max_angle - observed_min_angle
    angle_per_char = observed_range / (len(characters) - 1)

    for i, char in enumerate(characters):
        char_angle = observed_min_angle + i * angle_per_char
        plt.axhline(y=char_angle, color='blue', linestyle='--', alpha=0.3)
        plt.text(len(angles) + 10, char_angle, char, fontsize=10, va='center')

    if stable_segments:
        char_angles = {char: observed_min_angle + i * angle_per_char for i, char in enumerate(characters)}

        for i, (start, end, avg_angle, duration) in enumerate(stable_segments):
            plt.plot(
                range(start, end + 1),
                angles[start : end + 1],
                color='red',
                linewidth=2,
                alpha=0.7,
            )

            plt.axhline(
                y=avg_angle,
                xmin=start / len(angles),
                xmax=end / len(angles),
                color='darkred',
                linewidth=1,
                alpha=0.4,
                linestyle='--',
            )

            closest_char = None
            min_diff = float('inf')
            for char, char_angle in char_angles.items():
                diff = abs(avg_angle - char_angle)
                if diff < min_diff:
                    min_diff = diff
                    closest_char = char

            if min_diff < angle_per_char / 2:
                mid_x = (start + end) / 2
                plt.text(
                    mid_x,
                    avg_angle + 5,
                    closest_char,
                    fontsize=12,
                    fontweight='bold',
                    ha='center',
                    va='center',
                    bbox=dict(facecolor='white', alpha=0.7, edgecolor='none', pad=1),
                )

                plt.text(
                    start,
                    avg_angle - 5,
                    f'{i}',
                    fontsize=8,
                    color='darkred',
                    ha='left',
                    va='top',
                )

    plt.xlabel('Pulse Number')
    plt.ylabel('Angle (degrees)')
    plt.title('Mechanical Display Angle Over Time with Character Labels')

    plt.ylim(-100, 95)

    plt.grid(True, alpha=0.3)

    plt.legend(['Angle', 'Stable positions'], loc='lower right')

    plt.savefig('angle_over_time_labeled.png', dpi=300)

    plt.tight_layout()
    plt.show()


def main() -> None:
    vcd_file = 'mechanical-display.vcd'

    changes = parse_vcd(vcd_file)

    pulse_widths = calculate_pulse_widths(changes)

    angles = [pulse_width_to_angle(pw) for pw in pulse_widths]

    segments = []
    current_angle = angles[0]
    current_start = 0
    angle_threshold = 3.0

    for i, angle in enumerate(angles[1:], 1):
        if abs(angle - current_angle) > angle_threshold:
            duration = i - current_start
            avg_angle = sum(angles[current_start:i]) / duration
            segments.append((current_start, i - 1, avg_angle, duration))

            current_start = i
            current_angle = angle

    if current_start < len(angles):
        duration = len(angles) - current_start
        avg_angle = sum(angles[current_start:]) / duration
        segments.append((current_start, len(angles) - 1, avg_angle, duration))

    min_stable_duration = 20
    stable_segments = [s for s in segments if s[3] >= min_stable_duration]

    print(f'Total segments: {len(segments)}')
    print(f'Stable segments: {len(stable_segments)}')

    plot_angles_over_time(angles, stable_segments)

    clean_message = decode_stable_positions(angles)
    print('Decoded message (stable positions):', clean_message)

    flag_pattern = re.compile(r'FCSC\{[^}]*\}')
    matches = flag_pattern.findall(clean_message)
    if matches:
        print('Flag found:')
        for match in matches:
            print(match)


def decode_stable_positions(angles: list[float]) -> str:
    observed_min_angle = -95
    observed_max_angle = 90.2
    observed_range = observed_max_angle - observed_min_angle
    angle_per_char = observed_range / (len(characters) - 1)

    char_angles = {char: observed_min_angle + i * angle_per_char for i, char in enumerate(characters)}

    segments = []
    current_angle = angles[0]
    current_start = 0
    angle_threshold = 3.0

    for i, angle in enumerate(angles[1:], 1):
        if abs(angle - current_angle) > angle_threshold:
            duration = i - current_start
            if duration > 0:
                avg_angle = sum(angles[current_start:i]) / duration
                segments.append((current_start, i - 1, avg_angle, duration))

            current_start = i
            current_angle = angle

    if current_start < len(angles):
        duration = len(angles) - current_start
        if duration > 0:
            avg_angle = sum(angles[current_start:]) / duration
            segments.append((current_start, len(angles) - 1, avg_angle, duration))

    min_stable_duration = 20
    stable_segments = [s for s in segments if s[3] >= min_stable_duration]

    message = ''
    for start_idx, end_idx, avg_angle, duration in stable_segments:
        min_diff = float('inf')
        closest_char = None

        for char, char_angle in char_angles.items():
            diff = abs(avg_angle - char_angle)
            if diff < min_diff:
                min_diff = diff
                closest_char = char

        if closest_char is not None and min_diff < angle_per_char / 2.0:
            message += closest_char
        else:
            raise Warning(
                f'Angle {avg_angle} does not match any character. Closest character: {closest_char}, '
                f'Duration: {duration}, Start: {start_idx}, End: {end_idx}',
            )

    return message


if __name__ == '__main__':
    main()