diff --git a/database/ehinge.db b/database/ehinge.db index 651a89e..6d661e6 100644 Binary files a/database/ehinge.db and b/database/ehinge.db differ diff --git a/decoder.py b/decoder.py index 70d880f..f5ebc9f 100644 --- a/decoder.py +++ b/decoder.py @@ -4,168 +4,247 @@ Decoder Module - vzug-e-hinge ============================== Decodes raw UART and I2C data into telemetry fields. -Current Implementation: Simple pass-through -TODO: Implement actual byte unpacking later +UART Packet Format (14 bytes, Little Endian / LSB first): + Byte 0-1: Start marker EF FE + Byte 2-3: MotorCurrent (signed 16-bit, LSB first) + Byte 4-5: EncoderValue (signed 16-bit, LSB first) + Byte 6-7: RelativeEncoderValue (signed 16-bit, LSB first) + Byte 8-9: V24PecDiff (signed 16-bit, LSB first) + Byte 10-11: Reserved0 (signed 16-bit, LSB first) + Byte 12: PWM (unsigned 8-bit, 0-100) + Byte 13: End marker EE + +I2C Format (2 bytes, 14-bit angle from AS5600/AS5048A): + Byte 0: High byte (bits 13-6) + Byte 1: Low byte (bits 5-0) + +Reference: eHinge Encoder.pdf, Datalogger files Author: Kynsight -Version: 1.0.0 (pass-through) -Date: 2025-11-09 +Version: 2.0.0 (full decoding) +Date: 2025-11-22 """ -from typing import Dict, Any +from typing import Dict, Any, Optional + +# UART Packet Constants +FRAME_LEN = 14 +START0 = 0xEF +START1 = 0xFE +END = 0xEE + + +def _le_s16(b0: int, b1: int) -> int: + """Decode Little Endian signed 16-bit value (LSB first).""" + u = (b1 << 8) | b0 # b1 is high byte, b0 is low byte + return u - 0x10000 if (u & 0x8000) else u def decode_uart_packet(packet_bytes: bytes) -> Dict[str, Any]: """ Decode UART packet bytes into telemetry fields. - - Current Implementation: Pass-through (returns raw data) - TODO: Implement actual decoding based on packet format - - Expected packet format: EF FE [14 bytes] EE (17 bytes total) - - Future implementation should extract: - - motor_current (2 bytes) - - encoder_value (2 bytes) - - relative_encoder_value (2 bytes) - - v24_pec_diff (2 bytes) - - pwm (1 byte) - + + Packet format: EF FE [data] EE (14 bytes total, Little Endian / LSB first) + Args: - packet_bytes: Raw packet bytes (including start/end markers) - + packet_bytes: Raw packet bytes (14 bytes including start/end markers) + Returns: - Dictionary with decoded fields (currently just raw data) - + Dictionary with decoded fields or None values if invalid + Example: - packet_bytes = b'\\xEF\\xFE...[14 bytes]...\\xEE' + packet_bytes = b'\\xEF\\xFE\\x04\\x00\\xED\\xFF\\x00\\x00\\x20\\x08\\x00\\x00\\x00\\xEE' decoded = decode_uart_packet(packet_bytes) - # Currently returns: {'raw_hex': 'ef fe ... ee', 'raw_bytes': b'...'} - # Future: {'motor_current': 123, 'encoder_value': 456, ...} + # Returns: {'motor_current': 4, 'encoder_value': -19, 'v24_pec_diff': 2080, ...} """ + # Validate packet + if packet_bytes is None or len(packet_bytes) != FRAME_LEN: + return { + 'motor_current': None, + 'encoder_value': None, + 'relative_encoder_value': None, + 'v24_pec_diff': None, + 'pwm': None, + 'reserved0': None, + 'error': f'Invalid packet length: {len(packet_bytes) if packet_bytes else 0}, expected {FRAME_LEN}' + } + + # Verify markers + if not (packet_bytes[0] == START0 and packet_bytes[1] == START1 and packet_bytes[13] == END): + return { + 'motor_current': None, + 'encoder_value': None, + 'relative_encoder_value': None, + 'v24_pec_diff': None, + 'pwm': None, + 'reserved0': None, + 'error': f'Invalid markers: start={packet_bytes[0]:02X} {packet_bytes[1]:02X}, end={packet_bytes[13]:02X}' + } + + # Decode fields (all Little Endian signed 16-bit except PWM) + motor_current = _le_s16(packet_bytes[2], packet_bytes[3]) + encoder_value = _le_s16(packet_bytes[4], packet_bytes[5]) + relative_encoder_value = _le_s16(packet_bytes[6], packet_bytes[7]) + v24_pec_diff = _le_s16(packet_bytes[8], packet_bytes[9]) + reserved0 = _le_s16(packet_bytes[10], packet_bytes[11]) + pwm = int(packet_bytes[12]) # Unsigned 8-bit (0-100) + + # Optional sanity check (adjust bounds as needed) + if not (-40000 <= motor_current <= 40000 and 0 <= pwm <= 100): + return { + 'motor_current': motor_current, + 'encoder_value': encoder_value, + 'relative_encoder_value': relative_encoder_value, + 'v24_pec_diff': v24_pec_diff, + 'pwm': pwm, + 'reserved0': reserved0, + 'error': f'Out of range: motor_current={motor_current}, pwm={pwm}' + } + return { - 'raw_hex': packet_bytes.hex(' '), - 'raw_bytes': packet_bytes, - 'packet_length': len(packet_bytes) + 'motor_current': motor_current, + 'encoder_value': encoder_value, + 'relative_encoder_value': relative_encoder_value, + 'v24_pec_diff': v24_pec_diff, + 'pwm': pwm, + 'reserved0': reserved0, + 'error': None } -def decode_i2c_sample(i2c_bytes: bytes) -> Dict[str, Any]: +def _wrap14_delta(current: int, zero: int) -> int: + """ + Calculate signed wrap-around delta in 14-bit space. + + Maps (current - zero) to range [-8192..+8191] handling wrap-around. + + Args: + current: Current 14-bit value (0-16383) + zero: Zero reference 14-bit value (0-16383) + + Returns: + Signed delta in range [-8192, +8191] + + Example: + _wrap14_delta(100, 16300) = 184 # Wrapped forward + _wrap14_delta(16300, 100) = -184 # Wrapped backward + """ + return ((current - zero + 8192) % 16384) - 8192 + + +def decode_i2c_sample(i2c_bytes: Optional[bytes], zero_ref: int = 0) -> Dict[str, Any]: """ - Decode I2C sample bytes into angle telemetry. - - Current Implementation: Pass-through (returns raw data) - TODO: Implement actual decoding based on I2C format - - Expected I2C format: 2 bytes (14-bit angle value) - - Future implementation should extract: - - i2c_raw14 (14-bit raw value) - - i2c_angle_deg (converted to degrees) - - i2c_zero_raw14 (zero position) - - i2c_delta_raw14 (delta from zero) - + Decode I2C sample bytes into angle telemetry with zero reference. + + I2C format: 2 bytes (14-bit angle from AS5600/AS5048A sensor) + Byte 0: High byte (bits 13-6 of angle) + Byte 1: Low byte (bits 5-0 of angle) + Args: - i2c_bytes: Raw I2C bytes (typically 2 bytes for angle) - + i2c_bytes: Raw I2C bytes (2 bytes) + zero_ref: Zero reference value (0-16383), default 0 + Returns: - Dictionary with decoded fields (currently just raw data) - + Dictionary with decoded I2C angle fields + Example: - i2c_bytes = b'\\x3F\\xFF' # 14-bit angle - decoded = decode_i2c_sample(i2c_bytes) - # Currently returns: {'raw_hex': '3f ff', 'raw_bytes': b'...'} - # Future: {'i2c_raw14': 16383, 'i2c_angle_deg': 359.98, ...} + i2c_bytes = b'\\xEC\\x36' # Sample from sensor + decoded = decode_i2c_sample(i2c_bytes, zero_ref=11318) + # Returns: { + # 'i2c_raw14': 11318, + # 'i2c_zero_raw14': 11318, + # 'i2c_delta_raw14': 0, + # 'i2c_angle_deg': 0.0, + # 'i2c_zero_angle_deg': 248.69 + # } """ + # Validate input + if i2c_bytes is None or len(i2c_bytes) != 2: + return { + 'i2c_raw14': None, + 'i2c_zero_raw14': zero_ref if zero_ref != 0 else None, + 'i2c_delta_raw14': None, + 'i2c_angle_deg': None, + 'i2c_zero_angle_deg': (zero_ref * 360.0 / 16384.0) if zero_ref != 0 else None, + 'error': f'Invalid I2C length: {len(i2c_bytes) if i2c_bytes else 0}, expected 2' + } + + # Decode 14-bit value + # Format: MSB contains bits [13:6], LSB contains bits [5:0] + msb, lsb = i2c_bytes[0], i2c_bytes[1] + raw14 = ((msb << 6) | (lsb & 0x3F)) & 0x3FFF + + # Calculate delta from zero with wrap-around + delta14 = _wrap14_delta(raw14, zero_ref) if zero_ref != 0 else None + + # Convert to degrees + # NOTE: Negative sign to match coordinate system (clockwise positive) + angle_deg = -(delta14 * 360.0 / 16384.0) if delta14 is not None else None + zero_angle_deg = (zero_ref * 360.0 / 16384.0) if zero_ref != 0 else None + return { - 'raw_hex': i2c_bytes.hex(' '), - 'raw_bytes': i2c_bytes, - 'sample_length': len(i2c_bytes) + 'i2c_raw14': raw14, + 'i2c_zero_raw14': zero_ref if zero_ref != 0 else None, + 'i2c_delta_raw14': delta14, + 'i2c_angle_deg': angle_deg, + 'i2c_zero_angle_deg': zero_angle_deg, + 'error': None } # ============================================================================= -# Future Implementation Template +# Test Code # ============================================================================= -# def decode_uart_packet_full(packet_bytes: bytes) -> Dict[str, Any]: -# """ -# Full UART packet decoder (to be implemented). -# -# Packet format: EF FE [14 bytes] EE -# -# Byte layout: -# [0-1]: Start marker (EF FE) -# [2-3]: motor_current (signed 16-bit, little-endian) -# [4-5]: encoder_value (unsigned 16-bit, little-endian) -# [6-7]: relative_encoder_value (signed 16-bit, little-endian) -# [8-9]: v24_pec_diff (signed 16-bit, little-endian) -# [10]: pwm (unsigned 8-bit) -# [11-15]: Reserved -# [16]: End marker (EE) -# """ -# # Verify packet length -# if len(packet_bytes) != 17: -# raise ValueError(f"Invalid packet length: {len(packet_bytes)}, expected 17") -# -# # Verify markers -# if packet_bytes[0:2] != b'\xEF\xFE': -# raise ValueError("Invalid start marker") -# if packet_bytes[16:17] != b'\xEE': -# raise ValueError("Invalid end marker") -# -# # Extract data bytes (skip markers) -# data = packet_bytes[2:16] -# -# return { -# 'motor_current': int.from_bytes(data[0:2], 'little', signed=True), -# 'encoder_value': int.from_bytes(data[2:4], 'little', signed=False), -# 'relative_encoder_value': int.from_bytes(data[4:6], 'little', signed=True), -# 'v24_pec_diff': int.from_bytes(data[6:8], 'little', signed=True), -# 'pwm': data[8] -# } - - -# def decode_i2c_sample_full(i2c_bytes: bytes) -> Dict[str, Any]: -# """ -# Full I2C sample decoder (to be implemented). -# -# I2C format: 2 bytes (14-bit angle value) -# -# Byte layout: -# [0]: High byte (bits 13-6) -# [1]: Low byte (bits 5-0 in upper 6 bits) -# """ -# if len(i2c_bytes) != 2: -# raise ValueError(f"Invalid I2C sample length: {len(i2c_bytes)}, expected 2") -# -# # Extract 14-bit value -# raw14 = ((i2c_bytes[0] << 6) | (i2c_bytes[1] >> 2)) & 0x3FFF -# -# # Convert to degrees (14-bit = 0-360°) -# angle_deg = (raw14 / 16384.0) * 360.0 -# -# return { -# 'i2c_raw14': raw14, -# 'i2c_angle_deg': angle_deg -# } +if __name__ == "__main__": + print("Decoder Module - Full Implementation") + print("=" * 70) + print() + # Test UART packet decoding (from PDF example) + # EF FE | 04 00 | ED FF | 00 00 | 20 08 | 00 00 | 00 | EE + # Expected: MotorCurrent=4, EncoderValue=-19, Relative=0, V24=2080, PWM=0 + test_uart = bytes([ + 0xEF, 0xFE, # Start + 0x04, 0x00, # MotorCurrent = 4 (Little Endian: LSB first) + 0xED, 0xFF, # EncoderValue = -19 (Little Endian: LSB first) + 0x00, 0x00, # RelativeEncoderValue = 0 + 0x20, 0x08, # V24PecDiff = 2080 (Little Endian: LSB first) + 0x00, 0x00, # Reserved0 = 0 + 0x00, # PWM = 0 + 0xEE # End + ]) -if __name__ == "__main__": - # Simple test - print("Decoder Module - Pass-Through Mode") - print("=" * 60) - - # Test UART packet decoding - test_uart = b'\xEF\xFE' + b'\x01' * 14 + b'\xEE' decoded_uart = decode_uart_packet(test_uart) - print(f"UART packet decoded: {decoded_uart}") - - # Test I2C sample decoding - test_i2c = b'\x3F\xFF' - decoded_i2c = decode_i2c_sample(test_i2c) - print(f"I2C sample decoded: {decoded_i2c}") - + print("UART Packet Test:") + print(f" Hex: {test_uart.hex(' ').upper()}") + print(f" Decoded:") + for key, val in decoded_uart.items(): + print(f" {key:25s} = {val}") print() - print("✓ Decoder ready (pass-through mode)") - print("TODO: Implement actual decoding later") + + # Test I2C angle decoding (from your log) + # EC 36 = 11318 raw14 = 248.69° + test_i2c = bytes([0xEC, 0x36]) + decoded_i2c = decode_i2c_sample(test_i2c, zero_ref=0) + print("I2C Angle Test (no zero):") + print(f" Hex: {test_i2c.hex(' ').upper()}") + print(f" Decoded:") + for key, val in decoded_i2c.items(): + if val is not None: + print(f" {key:25s} = {val}") + print() + + # Test I2C with zero reference + decoded_i2c_zero = decode_i2c_sample(test_i2c, zero_ref=11318) + print("I2C Angle Test (with zero=11318):") + print(f" Hex: {test_i2c.hex(' ').upper()}") + print(f" Decoded:") + for key, val in decoded_i2c_zero.items(): + if val is not None: + print(f" {key:25s} = {val}") + print() + + print("✓ Decoder ready (full implementation)") + print("✓ UART: Little Endian (LSB first), signed values") + print("✓ I2C: 14-bit angle with zero reference and wrap-around") diff --git a/decoder_old/decode_raw_data.py b/decoder_old/decode_raw_data.py new file mode 100644 index 0000000..2670475 --- /dev/null +++ b/decoder_old/decode_raw_data.py @@ -0,0 +1,519 @@ +# gui/components/data/decode_raw_data.py +# ============================================================================= +# Purpose +# ------- +# Decode raw rows from **one session** in 'telemetry' into 'decoded_telemetry'. +# +# Scope +# ----- +# - This decoder processes **only a single session_id**. +# - If session_id is not provided, it auto-selects the **most recent** session +# present in 'telemetry' (by MAX(t_ns)). +# +# What it does +# ------------ +# - Reads rows from telemetry for the chosen session_id. +# - Copies session metadata **exactly** from telemetry: +# session_id, logger_session, logger_session_number. +# - Decodes UART 14B frames (EF FE ... EE), Big Endian pairs: +# [2..3]=u16 MotorCurrent +# [4..5]=s16 EncoderValue +# [6..7]=s16 RelativeEncoderValue +# [8..9]=s16 V24PecDiff +# [10..11]=s16 Reserved0 +# [12]=u8 PWM +# - Computes time_ms per (session_id, logger_session_number) relative to first t_ns. +# - Parses I2C 2B angle (AS5048A) into: +# i2c_raw14, i2c_zero_raw14 (first seen per (session_id, logger_session_number)), +# i2c_delta_raw14 (signed wrap), +# i2c_angle_deg (-(delta*360/16384)), +# i2c_zero_angle_deg (zero*360/16384) +# - Upserts decoded rows into 'decoded_telemetry' (no i2c_raw stored). +# +# Entry points +# ------------ +# - decode_into_decoded_telemetry(session_id: str|None = None, batch_size: int = 1000) -> dict +# - run(session_id: str|None = None, **_) -> dict (alias; ignores other filters) +# - main([session_id]) -> int (simple CLI) +# ============================================================================= + +from __future__ import annotations +from typing import Optional, Tuple, Dict, Any, List + +# DB helpers (centralized) +from components.data.db import ( + get_connection, + ensure_decoded_schema, + upsert_decoded_rows, +) + +# ----------------------------- +# UART framing & decode helpers +# ----------------------------- +FRAME_LEN = 14 +START0 = 0xEF +START1 = 0xFE +END = 0xEE + + +def _parse_hex_bytes(s: Optional[str]) -> Optional[bytes]: + """Accepts 'EF FE ... EE' or 'EFFEEE...' (spaces optional). Returns None if invalid/missing.""" + if not s: + return None + s2 = s.strip().replace(" ", "") + if len(s2) % 2 != 0: + return None + try: + return bytes.fromhex(s2) + except ValueError: + return None + + +def _be_u16(b0: int, b1: int) -> int: + return (b0 << 8) | b1 + + +def _be_s16(b0: int, b1: int) -> int: + u = (b0 << 8) | b1 + return u - 0x10000 if (u & 0x8000) else u + + +def _decode_uart_frame( + raw: Optional[bytes], +) -> Optional[Tuple[int, int, int, int, int, int]]: + if raw is None or len(raw) != FRAME_LEN: + return None + if not (raw[0] == START0 and raw[1] == START1 and raw[13] == END): + return None + + # motor_current must be signed s16 (two's complement, big endian) + mc = _be_s16(raw[2], raw[3]) + enc = _be_s16(raw[4], raw[5]) + rel = _be_s16(raw[6], raw[7]) + v24 = _be_s16(raw[8], raw[9]) + res0 = _be_s16(raw[10], raw[11]) + pwm = int(raw[12]) # u8 (0..100) + + # Optional sanity guard to drop obvious misframes (tune bounds to your hw) + if not (-40000 <= mc <= 40000 and 0 <= pwm <= 100): + return None + + return (mc, enc, rel, v24, res0, pwm) + + +# ----------------------------- +# I2C decode helpers (AS5048A) +# ----------------------------- +def _i2c_raw14(i2c_raw: Optional[str]) -> Optional[int]: + """Decode 2-byte I2C payload into 14-bit angle (0..16383).""" + b = _parse_hex_bytes(i2c_raw) + if b is None or len(b) != 2: + return None + msb, lsb = b[0], b[1] + return ((msb << 6) | (lsb & 0x3F)) & 0x3FFF + + +def _wrap14_delta(cur: int, zero: int) -> int: + """Signed wrap-around delta (cur - zero) mapped to [-8192..+8191] in 14-bit space.""" + return ((cur - zero + 8192) % 16384) - 8192 + + +# ----------------------------- +# Session selection helpers +# ----------------------------- +def _resolve_session_id() -> Optional[str]: + """ + Pick the most recent session_id present in telemetry (by MAX(t_ns)). + Returns None if telemetry is empty. + """ + with get_connection() as con: + cur = con.cursor() + cur.execute( + """ + SELECT session_id + FROM telemetry + WHERE t_ns IS NOT NULL + GROUP BY session_id + ORDER BY MAX(t_ns) DESC + LIMIT 1 + """ + ) + row = cur.fetchone() + return row[0] if row else None + + +# ----------------------------- +# Internal: load rows for one session +# ----------------------------- +_SELECT_FOR_SESSION = """ +SELECT + id, session_id, logger_session, logger_session_number, + t_ns, uart_raw, i2c_raw +FROM telemetry +WHERE session_id = ? +ORDER BY logger_session_number, t_ns +""" + + +# ----------------------------- +# Public: main decode entry +# ----------------------------- +def decode_into_decoded_telemetry( + session_id: Optional[str] = None, + *, + batch_size: int = 1000, +) -> Dict[str, int]: + ensure_decoded_schema() + + sess = session_id or _resolve_session_id() + if not sess: + return {"processed": 0, "decoded": 0, "skipped_invalid_uart": 0} + + # Load rows for this session + with get_connection() as con: + cur = con.cursor() + cur.execute(_SELECT_FOR_SESSION, (sess,)) + rows = cur.fetchall() + + processed = decoded = skipped = 0 + + first_ns_map: Dict[Tuple[str, int], int] = {} + i2c_zero_map: Dict[Tuple[str, int], int] = {} + + try: + import config + + _CONFIG_ZERO14 = ( + ((int(config.SESSION_ZERO[0]) & 0xFF) << 8) + | (int(config.SESSION_ZERO[1]) & 0xFF) + ) & 0x3FFF + except Exception: + _CONFIG_ZERO14 = None + + # 1) PASS: decode & COLLECT per run + from collections import defaultdict + + grp_rows: Dict[Tuple[str, int], List[Dict[str, Any]]] = defaultdict(list) + + for id_, sess_id, log_tag, log_no, t_ns, uart_raw, i2c_raw in rows: + processed += 1 + grp = (sess_id, int(log_no)) + + if _CONFIG_ZERO14 is not None: + i2c_zero_map.setdefault(grp, _CONFIG_ZERO14) + + # baseline for time_ms + if grp not in first_ns_map and t_ns is not None: + first_ns_map[grp] = t_ns + t0 = first_ns_map.get(grp) + time_ms = ( + int((t_ns - t0) / 1_000_000) + if (t0 is not None and t_ns is not None) + else None + ) + + # UART decode + frame = _decode_uart_frame(_parse_hex_bytes(uart_raw)) + if frame is None: + skipped += 1 + continue + mc, enc, rel, v24, res0, pwm = frame + + # I2C decode/angles + raw14 = _i2c_raw14(i2c_raw) + zero14 = i2c_zero_map.get(grp) + if raw14 is not None and zero14 is None: + zero14 = raw14 + i2c_zero_map[grp] = zero14 + + if raw14 is not None and zero14 is not None: + delta14 = _wrap14_delta(raw14, zero14) + angle_deg = -(delta14 * 360.0 / 16384.0) + zero_angle_deg = zero14 * 360.0 / 16384.0 + else: + delta14 = None + angle_deg = None + zero_angle_deg = (zero14 * 360.0 / 16384.0) if zero14 is not None else None + + grp_rows[grp].append( + { + "id": id_, + "session_id": sess_id, + "logger_session": log_tag, + "logger_session_number": int(log_no), + "t_ns": t_ns, + "time_ms": time_ms, + "motor_current": mc, + "encoder_value": enc, + "relative_encoder_value": rel, + "v24_pec_diff": v24, + "pwm": pwm, + "i2c_raw14": raw14, + "i2c_zero_raw14": zero14, + "i2c_delta_raw14": delta14, + "i2c_angle_deg": angle_deg, + "i2c_zero_angle_deg": zero_angle_deg, + "angular_velocity": None, + } + ) + + # 2) PASS: compute angular_velocity per group using Kalman RTS on angle vs time + for grp, items in grp_rows.items(): + # Build time in seconds with full precision; keep your existing time_ms for storage + time_s_list = [] + angle_deg_list = [] + for row in items: + t_ns = row["t_ns"] + # use high-precision seconds internally + time_s_list.append( + (t_ns - items[0]["t_ns"]) * 1e-9 + if (t_ns is not None and items[0]["t_ns"] is not None) + else None + ) + angle_deg_list.append( + row["i2c_angle_deg"] + ) # may be None; RTS handles segments + + omega_deg_s = _kalman_velocity_over_series( + time_s_list, angle_deg_list, gap_threshold_s=0.1 + ) + + # write back + for row, w in zip(items, omega_deg_s): + row["angular_velocity"] = w + + # 3) UPSERT in batches (same API) + out_batch: List[Dict[str, Any]] = [] + + def _flush_batch(): + nonlocal out_batch, decoded + if not out_batch: + return + decoded += upsert_decoded_rows(out_batch) + out_batch = [] + + for items in grp_rows.values(): + for row in items: + out_batch.append(row) + if len(out_batch) >= batch_size: + _flush_batch() + _flush_batch() + + return {"processed": processed, "decoded": decoded, "skipped_invalid_uart": skipped} + + +# ----------------------------- +# Kalman RTS (constant-velocity) helpers +# ----------------------------- +def _robust_var(values): + """Robust variance via MAD; returns a small floor if data is tiny.""" + xs = [v for v in values if v is not None] + if len(xs) < 5: + return 1e-6 + xs_sorted = sorted(xs) + m = xs_sorted[len(xs) // 2] + abs_dev = [abs(v - m) for v in xs_sorted] + mad = sorted(abs_dev)[len(abs_dev) // 2] or 1e-9 + # 1.4826 * MAD ~ sigma (Gaussian) + sigma = 1.4826 * mad + v = sigma * sigma + return v if v > 1e-6 else 1e-6 + + +def _kalman_rts_velocity_segment(t_s, y_deg, r_var=None, q_pow=None): + """ + One contiguous segment (strictly increasing times, no big gaps). + State x=[theta, omega], units: theta=deg, omega=deg/s. + F_k = [[1, dt],[0,1]] + Q_k = q * [[dt^3/3, dt^2/2],[dt^2/2, dt]] + H = [1, 0], R = r. + Returns (theta_s, omega_s) lists (len = len(y_deg)) + """ + n = len(y_deg) + if n == 0: + return [], [] + # Estimate R (measurement variance) if not provided + if r_var is None: + r_var = _robust_var(y_deg) + # Estimate process noise power q if not provided (based on rough accel scale) + if q_pow is None: + # crude accel estimate from finite differences + dtheta = [(y_deg[i + 1] - y_deg[i]) for i in range(n - 1)] + dt = [(t_s[i + 1] - t_s[i]) for i in range(n - 1)] + dv = [] + for i in range(len(dtheta) - 1): + dt1 = dt[i] if dt[i] > 1e-6 else 1e-6 + dt2 = dt[i + 1] if dt[i + 1] > 1e-6 else 1e-6 + v1 = dtheta[i] / dt1 + v2 = dtheta[i + 1] / dt2 + dv.append( + (v2 - v1) / max((dt1 + dt2) / 2.0, 1e-6) + ) # deg/s^2 change per second + # variance-like scale of acceleration + acc_var = _robust_var(dv) if dv else 1.0 + med_dt = sorted(dt)[len(dt) // 2] if dt else 0.01 + q_pow = max(acc_var * med_dt * 1e-3, 1e-6) # conservative floor + + # Allocate + x_f = [[0.0, 0.0] for _ in range(n)] + P_f = [[[0.0, 0.0], [0.0, 0.0]] for _ in range(n)] + x_p = [[0.0, 0.0] for _ in range(n)] + P_p = [[[0.0, 0.0], [0.0, 0.0]] for _ in range(n)] + + # Init + x = [y_deg[0], 0.0] + P = [[1.0, 0.0], [0.0, 1000.0]] + + H = [1.0, 0.0] + R = r_var + + def mat_add(A, B): + return [ + [A[0][0] + B[0][0], A[0][1] + B[0][1]], + [A[1][0] + B[1][0], A[1][1] + B[1][1]], + ] + + def mat_mul(A, B): + return [ + [ + A[0][0] * B[0][0] + A[0][1] * B[1][0], + A[0][0] * B[0][1] + A[0][1] * B[1][1], + ], + [ + A[1][0] * B[0][0] + A[1][1] * B[1][0], + A[1][0] * B[0][1] + A[1][1] * B[1][1], + ], + ] + + def mat_transpose(A): + return [[A[0][0], A[1][0]], [A[0][1], A[1][1]]] + + def mat_vec(A, v): + return [A[0][0] * v[0] + A[0][1] * v[1], A[1][0] * v[0] + A[1][1] * v[1]] + + def mat_inv_2x2(A): + det = A[0][0] * A[1][1] - A[0][1] * A[1][0] + if abs(det) < 1e-12: + det = 1e-12 + inv = [[A[1][1] / det, -A[0][1] / det], [-A[1][0] / det, A[0][0] / det]] + return inv + + # FILTER + for k in range(n): + if k == 0: + x_p[k] = [x[0], x[1]] + P_p[k] = [[P[0][0], P[0][1]], [P[1][0], P[1][1]]] + else: + dt = t_s[k] - t_s[k - 1] + F = [[1.0, dt], [0.0, 1.0]] + q11 = (dt**3) / 3.0 + q12 = (dt**2) / 2.0 + q22 = dt + Q = [[q_pow * q11, q_pow * q12], [q_pow * q12, q_pow * q22]] + # Predict + x = [x[0] + dt * x[1], x[1]] + FP = mat_mul(F, P) + FT = mat_transpose(F) + P = mat_add(mat_mul(FP, FT), Q) + x_p[k] = [x[0], x[1]] + P_p[k] = [[P[0][0], P[0][1]], [P[1][0], P[1][1]]] + + # Update with measurement y_deg[k] + z = y_deg[k] + # S = H*P*H^T + R (scalar) + S = P[0][0] + R + K0 = P[0][0] / S # first row, col 0 of K + K1 = P[1][0] / S # second row, col 0 of K + innov = z - x[0] + x = [x[0] + K0 * innov, x[1] + K1 * innov] + # P = (I - K*H)P + # (I - K H) = [[1-K0*1, -K0*0],[-K1*1, 1- K1*0]] = [[1-K0, 0],[-K1, 1]] + IKH = [[1.0 - K0, 0.0], [-K1, 1.0]] + P = mat_mul(IKH, P) + + x_f[k] = [x[0], x[1]] + P_f[k] = [[P[0][0], P[0][1]], [P[1][0], P[1][1]]] + + # RTS SMOOTHER + x_s = [[x_f[i][0], x_f[i][1]] for i in range(n)] + P_s = [ + [[P_f[i][0][0], P_f[i][0][1]], [P_f[i][1][0], P_f[i][1][1]]] for i in range(n) + ] + + for k in range(n - 2, -1, -1): + dt = t_s[k + 1] - t_s[k] + F = [[1.0, dt], [0.0, 1.0]] + FT = mat_transpose(F) + Pp_inv = mat_inv_2x2(P_p[k + 1]) + # Ck = P_f[k] * F^T * P_p[k+1]^{-1} + C_left = mat_mul(P_f[k], FT) + Ck = mat_mul(C_left, Pp_inv) + # x_s[k] = x_f[k] + Ck * (x_s[k+1] - x_p[k+1]) + dx = [x_s[k + 1][0] - x_p[k + 1][0], x_s[k + 1][1] - x_p[k + 1][1]] + corr = mat_vec(Ck, dx) + x_s[k] = [x_f[k][0] + corr[0], x_f[k][1] + corr[1]] + # P_s[k] not needed for velocity output, so we skip computing it for speed + + theta_s = [x_s[i][0] for i in range(n)] + omega_s = [x_s[i][1] for i in range(n)] + return theta_s, omega_s + + +def _kalman_velocity_over_series( + time_s_list, angle_deg_list, *, gap_threshold_s=0.1 +) -> list: + """ + Splits the series at big gaps or invalid points and runs the RTS smoother per segment. + Returns a list of angular velocities (deg/s) aligned with inputs (None where undefined). + """ + n = len(angle_deg_list) + out = [None] * n + # Build valid indices where both time and angle exist + valid = [ + (time_s_list[i] is not None) and (angle_deg_list[i] is not None) + for i in range(n) + ] + i = 0 + while i < n: + # skip invalids + while i < n and not valid[i]: + i += 1 + if i >= n: + break + j = i + 1 + # extend segment until gap or invalid + while j < n and valid[j]: + if (time_s_list[j] - time_s_list[j - 1]) > gap_threshold_s: + break + j += 1 + # segment [i, j) + t_seg = [time_s_list[k] for k in range(i, j)] + y_seg = [angle_deg_list[k] for k in range(i, j)] + theta_s, omega_s = _kalman_rts_velocity_segment(t_seg, y_seg) + for k, w in enumerate(omega_s): + out[i + k] = w + i = j + return out + + +# Friendly alias for UART_logic.stop_logger() +def run(session_id: Optional[str] = None, **_ignored) -> Dict[str, int]: + """ + Alias to decode_into_decoded_telemetry with **only** session_id honored. + Other kwargs are ignored intentionally. + """ + return decode_into_decoded_telemetry(session_id=session_id) + + +def main() -> int: + """CLI: python -m gui.components.data.decode_raw_data [session_id]""" + import sys + + sess = sys.argv[1] if len(sys.argv) > 1 else None + stats = decode_into_decoded_telemetry(session_id=sess) + print(stats) + return 0 + + +if __name__ == "__main__": + main() diff --git a/graph/__init__.py b/graph/__init__.py new file mode 100644 index 0000000..1066dcb --- /dev/null +++ b/graph/__init__.py @@ -0,0 +1,5 @@ +""" +vzug-e-hinge Graph Module +========================= +Data visualization and graph generation. +""" diff --git a/graph/graph_kit/__init__.py b/graph/graph_kit/__init__.py new file mode 100644 index 0000000..bd87dcb --- /dev/null +++ b/graph/graph_kit/__init__.py @@ -0,0 +1,3 @@ +""" +Graph Kit - Core plotting and widgets +""" diff --git a/graph/graph_kit/graph_core.py b/graph/graph_kit/graph_core.py index 82c38ee..dbc60ef 100644 --- a/graph/graph_kit/graph_core.py +++ b/graph/graph_kit/graph_core.py @@ -93,7 +93,7 @@ def plot_overlay( continue # Updated label format: "session_name run_no (name)" - label = f"{data.session_id} {data.run_no} ({data.run_name})" + label = f"{data.session_id} {data.run_no} ({data.session_name})" ax.plot(x_data, y_data, label=label, alpha=0.8, linestyle=config.linestyle, marker=config.marker, markersize=config.markersize) @@ -164,7 +164,7 @@ def plot_subplots( if x_data is None or y_data is None: ax.text(0.5, 0.5, 'No data', ha='center', va='center') - ax.set_title(f"{data.session_id} - Run {data.run_no} ({data.run_name})") + ax.set_title(f"{data.session_id} - Run {data.run_no} ({data.session_name})") continue ax.plot(x_data, y_data, alpha=0.8, @@ -173,7 +173,7 @@ def plot_subplots( ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) # Updated title format - ax.set_title(f"{data.session_id} {data.run_no} ({data.run_name})") + ax.set_title(f"{data.session_id} {data.run_no} ({data.session_name})") if config.grid: ax.grid(True, alpha=0.3) @@ -230,7 +230,7 @@ def plot_comparison( # Plot reference as baseline (zero) ax.axhline(y=0, color='black', linestyle='--', # Updated label format - label=f'Reference: {reference.session_id} {reference.run_no} ({reference.run_name})') + label=f'Reference: {reference.session_id} {reference.run_no} ({reference.session_name})') # Set color cycle ax.set_prop_cycle(color=plt.cm.tab10.colors) @@ -256,7 +256,7 @@ def plot_comparison( deviation = y_interp - ref_y # Updated label format - label = f"{data.session_id} {data.run_no} ({data.run_name})" + label = f"{data.session_id} {data.run_no} ({data.session_name})" ax.plot(ref_x, deviation, label=label, alpha=0.8, linestyle=config.linestyle, marker=config.marker, markersize=config.markersize) @@ -331,7 +331,7 @@ def plot_multi_series( continue # Updated label format - label = f"{data.session_id} {data.run_no} ({data.run_name})" + label = f"{data.session_id} {data.run_no} ({data.session_name})" ax.plot(x_data, y_data, label=label, alpha=0.8, linestyle=config.linestyle, marker=config.marker, markersize=config.markersize) @@ -395,7 +395,7 @@ def plot_xy_scatter( continue # Updated label format - label = f"{data.session_id} {data.run_no} ({data.run_name})" + label = f"{data.session_id} {data.run_no} ({data.session_name})" ax.plot(x_data, y_data, label=label, alpha=0.8, linestyle=config.linestyle, marker=config.marker or 'o', markersize=config.markersize if config.marker else 2, @@ -467,7 +467,7 @@ def export_csv( writer = csv.writer(f) # Header: metadata + X column + selected Y columns - header = ['session_id', 'run_name', 'run_no', x_column] + y_columns + header = ['session_id', 'session_name', 'run_no', x_column] + y_columns writer.writerow(header) # Data rows @@ -485,7 +485,7 @@ def export_csv( for i in range(length): row = [ data.session_id, - data.run_name, + data.session_name, data.run_no, x_data[i] ] diff --git a/graph/graph_kit/graph_core_widget.py b/graph/graph_kit/graph_core_widget.py index bc82bb9..b1d458c 100644 --- a/graph/graph_kit/graph_core_widget.py +++ b/graph/graph_kit/graph_core_widget.py @@ -25,6 +25,7 @@ from PyQt6.QtWidgets import ( ) from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QFont +from typing import Optional from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar @@ -42,7 +43,7 @@ from graph_table_query import ( ) # Import from graph_core (pure plotting) -from graph_core import ( +from .graph_core import ( PlotConfig, plot_overlay, plot_subplots, @@ -449,7 +450,7 @@ class GraphWidget(QWidget): for run in runs: run_item = QTreeWidgetItem(session_item) - run_item.setText(0, f"Run {run.run_number} ({run.run_name})") + run_item.setText(0, f"Run {run.run_number} ({run.session_name})") run_item.setText(1, f"{run.sample_count} samples") run_item.setText(2, f"{run.duration_ms:.1f}ms") run_item.setFlags(run_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) @@ -735,7 +736,7 @@ class GraphWidget(QWidget): continue # Label format: "session_name run_no (name) - series" - label = f"{data.session_id} {data.run_no} ({data.run_name}) - {ylabel}" + label = f"{data.session_id} {data.run_no} ({data.session_name}) - {ylabel}" ax.plot(x_data, y_data, label=label, alpha=0.8, linestyle=linestyle, marker=marker, markersize=markersize) @@ -775,22 +776,49 @@ class GraphWidget(QWidget): def _update_canvas(self, figure): """Update canvas with new figure - recreates canvas to fix matplotlib toolbar.""" - # Remove old canvas and toolbar - if hasattr(self, 'canvas'): - self.plot_layout.removeWidget(self.canvas) - self.plot_layout.removeWidget(self.toolbar) - self.canvas.deleteLater() - self.toolbar.deleteLater() - + from PyQt6.QtWidgets import QApplication + import matplotlib.pyplot as plt + + # Close old matplotlib figure to free memory + if hasattr(self, 'figure') and self.figure is not None: + try: + plt.close(self.figure) + except Exception: + pass + + # Remove old canvas and toolbar safely + if hasattr(self, 'canvas') and self.canvas is not None: + try: + self.plot_layout.removeWidget(self.canvas) + self.plot_layout.removeWidget(self.toolbar) + self.canvas.close() + self.toolbar.close() + self.canvas.deleteLater() + self.toolbar.deleteLater() + self.canvas = None + self.toolbar = None + # Process events to ensure cleanup + QApplication.processEvents() + except RuntimeError: + # Widget already deleted + pass + + # Store figure reference + self.figure = figure + # Create fresh canvas and toolbar self.canvas = FigureCanvas(figure) self.toolbar = NavigationToolbar(self.canvas, self) - + # Add to layout self.plot_layout.insertWidget(1, self.canvas) # After controls self.plot_layout.insertWidget(2, self.toolbar) # After canvas - - self.figure = figure + + # Draw the canvas + try: + self.canvas.draw() + except Exception as e: + print(f"Warning: Canvas draw failed: {e}") def _show_plot_window(self, figure): """Show plot in separate window.""" diff --git a/graph_table_query.py b/graph_table_query.py index ba0fbdd..04b3810 100644 --- a/graph_table_query.py +++ b/graph_table_query.py @@ -45,7 +45,7 @@ class SessionInfo: class RunInfo: """Run metadata.""" session_id: str - run_name: str + session_name: str run_number: int sample_count: int start_time_ns: int @@ -57,7 +57,7 @@ class RunInfo: class TelemetryData: """Decoded telemetry data (source-agnostic).""" session_id: str - run_name: str + session_name: str run_no: int # Time axes @@ -199,7 +199,7 @@ class SQLiteAdapter(DataAdapter): s.description, COUNT(DISTINCT t.run_no) as run_count FROM sessions s - LEFT JOIN decoded_telemetry t ON s.session_id = t.session_id + LEFT JOIN telemetry_decoded t ON s.session_id = t.session_id GROUP BY s.session_id ORDER BY s.created_at DESC """) @@ -228,27 +228,27 @@ class SQLiteAdapter(DataAdapter): cursor = self._conn.cursor() cursor.execute(""" - SELECT + SELECT session_id, - run_name, + session_name, run_no, COUNT(*) as sample_count, MIN(t_ns) as start_time_ns, MAX(t_ns) as end_time_ns - FROM decoded_telemetry + FROM telemetry_decoded WHERE session_id = ? - GROUP BY session_id, run_no, run_name + GROUP BY session_id, run_no, session_name ORDER BY run_no """, (session_id,)) - + runs = [] for row in cursor.fetchall(): duration_ns = row['end_time_ns'] - row['start_time_ns'] duration_ms = duration_ns / 1_000_000.0 - + runs.append(RunInfo( session_id=row['session_id'], - run_name=row['run_name'], + session_name=row['session_name'], run_number=row['run_no'], sample_count=row['sample_count'], start_time_ns=row['start_time_ns'], @@ -271,8 +271,8 @@ class SQLiteAdapter(DataAdapter): cursor = self._conn.cursor() cursor.execute(""" - SELECT - run_name, + SELECT + session_name, t_ns, time_ms, motor_current, @@ -286,23 +286,23 @@ class SQLiteAdapter(DataAdapter): i2c_angle_deg, i2c_zero_angle_deg, angular_velocity - FROM decoded_telemetry + FROM telemetry_decoded WHERE session_id = ? AND run_no = ? ORDER BY t_ns """, (session_id, run_no)) - + rows = cursor.fetchall() - + if not rows: return None - - # Get run_name from first row - run_name = rows[0]['run_name'] - + + # Get session_name from first row + session_name = rows[0]['session_name'] + # Extract columns data = TelemetryData( session_id=session_id, - run_name=run_name, + session_name=session_name, run_no=run_no, t_ns=self._extract_column(rows, 't_ns', dtype=np.int64), time_ms=self._extract_column(rows, 'time_ms', dtype=np.int64), @@ -352,7 +352,7 @@ class CSVAdapter(DataAdapter): CSV file adapter. Expected CSV format: - session_id,run_name,run_no,t_ns,time_ms,motor_current,encoder_value,... + session_id,session_name,run_no,t_ns,time_ms,motor_current,encoder_value,... Single CSV file containing all sessions and runs. """ @@ -374,7 +374,7 @@ class CSVAdapter(DataAdapter): self._df = pd.read_csv(self.csv_path) # Validate required columns - required = ['session_id', 'run_name', 'run_no'] + required = ['session_id', 'session_name', 'run_no'] missing = [col for col in required if col not in self._df.columns] if missing: @@ -438,8 +438,8 @@ class CSVAdapter(DataAdapter): for run_no, group in run_groups: sample_count = len(group) - # Get run_name - run_name = str(group['run_name'].iloc[0]) if 'run_name' in group.columns else '' + # Get session_name + session_name = str(group['session_name'].iloc[0]) if 'session_name' in group.columns else '' # Get time info if 't_ns' in group.columns: @@ -453,7 +453,7 @@ class CSVAdapter(DataAdapter): runs.append(RunInfo( session_id=session_id, - run_name=run_name, + session_name=session_name, run_number=int(run_no), sample_count=sample_count, start_time_ns=start_time_ns, @@ -489,13 +489,13 @@ class CSVAdapter(DataAdapter): if 't_ns' in run_df.columns: run_df = run_df.sort_values('t_ns') - # Get run_name - run_name = str(run_df['run_name'].iloc[0]) if 'run_name' in run_df.columns else '' + # Get session_name + session_name = str(run_df['session_name'].iloc[0]) if 'session_name' in run_df.columns else '' # Extract columns data = TelemetryData( session_id=session_id, - run_name=run_name, + session_name=session_name, run_no=run_no, t_ns=self._extract_column_csv(run_df, 't_ns', dtype=np.int64), time_ms=self._extract_column_csv(run_df, 'time_ms', dtype=np.int64), @@ -674,7 +674,7 @@ def align_data_to_reference( # Create aligned data object aligned = TelemetryData( session_id=data.session_id, - run_name=data.run_name, + session_name=data.session_name, run_no=data.run_no ) diff --git a/main.py b/main.py index 8d9fef4..abd9dea 100644 --- a/main.py +++ b/main.py @@ -34,16 +34,13 @@ from PyQt6.QtGui import QAction # Import database from database.init_database import DatabaseManager -# Import widgets (will be created next) -# from session_widget import SessionWidget -# from uart_widget import UARTWidget -# from uart_logger_widget import UARTLoggerWidget -# from i2c_widget import I2CWidget -# from i2c_logger_widget import I2CLoggerWidget -# from graph_widget import GraphWidget - -# Import session manager (will be created next) -# from session_manager import SessionManager +# Import widgets +from session_widget import SessionWidget +from configure_interface_widget import ConfigureInterfaceWidget +from configure_session_widget import ConfigureSessionWidget +from uart.uart_integrated_widget import UARTControlWidget +from i2c.i2c_integrated_widget import I2CControlWidget +from graph.graph_kit.graph_core_widget import GraphWidget class MainWindow(QMainWindow): @@ -242,8 +239,14 @@ class MainWindow(QMainWindow): self.i2c_widget = None # Tab 6: Graph - self.tab_graph = self._create_placeholder_tab("Graph Visualization") - self.tabs.addTab(self.tab_graph, "Graph") + try: + self.graph_widget = GraphWidget(db_path=self.db_path) + self.tabs.addTab(self.graph_widget, "Graph") + except Exception as e: + print(f"[WARN] Failed to load Graph widget: {e}") + self.tab_graph = self._create_placeholder_tab("Graph Visualization\n(Error loading)") + self.tabs.addTab(self.tab_graph, "Graph") + self.graph_widget = None def _create_placeholder_tab(self, title: str) -> QWidget: """Create placeholder tab widget.""" diff --git a/run.py b/run.py index b880bf7..762aa64 100644 --- a/run.py +++ b/run.py @@ -146,10 +146,6 @@ class RunExecutor: """ callback_count[0] += 1 - # Debug first few callbacks - if callback_count[0] <= 3: - print(f"[DEBUG] I2C callback triggered #{callback_count[0]} at {timestamp_ns}") - if i2c_port: # Read I2C angle immediately status, i2c_bytes = i2c_read_block( @@ -566,7 +562,7 @@ class RunExecutor: """ # Decode packets decoded_uart = decode_uart_packet(packet_info.data) - decoded_i2c = decode_i2c_sample(i2c_bytes) if i2c_bytes else None + decoded_i2c = decode_i2c_sample(i2c_bytes, self.i2c_zero_reference) if i2c_bytes else None # Calculate relative time from run start time_ms = (packet_info.start_timestamp - run_start_ns) / 1_000_000.0 @@ -590,14 +586,14 @@ class RunExecutor: self.i2c_zero_reference # Zero reference (0 if not zeroed) )) - # Save to telemetry_decoded (main data) - # For now, just save timestamps (decoder is pass-through) - # TODO: Update when decoder is fully implemented + # Save to telemetry_decoded (main data - all decoded fields) cursor.execute(""" INSERT INTO telemetry_decoded ( session_id, session_name, run_no, run_command_id, - t_ns, time_ms, i2c_zero_ref - ) VALUES (?, ?, ?, ?, ?, ?, ?) + t_ns, time_ms, + motor_current, encoder_value, relative_encoder_value, v24_pec_diff, pwm, + i2c_raw14, i2c_zero_raw14, i2c_delta_raw14, i2c_angle_deg, i2c_zero_angle_deg, i2c_zero_ref + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( session_id, session_name, @@ -605,13 +601,21 @@ class RunExecutor: run_command_id, packet_info.start_timestamp, time_ms, + # UART decoded fields + decoded_uart.get('motor_current'), + decoded_uart.get('encoder_value'), + decoded_uart.get('relative_encoder_value'), + decoded_uart.get('v24_pec_diff'), + decoded_uart.get('pwm'), + # I2C decoded fields + decoded_i2c.get('i2c_raw14') if decoded_i2c else None, + decoded_i2c.get('i2c_zero_raw14') if decoded_i2c else None, + decoded_i2c.get('i2c_delta_raw14') if decoded_i2c else None, + decoded_i2c.get('i2c_angle_deg') if decoded_i2c else None, + decoded_i2c.get('i2c_zero_angle_deg') if decoded_i2c else None, self.i2c_zero_reference # Zero reference (0 if not zeroed) )) - # TODO: When decoder is fully implemented, also save: - # UART: motor_current, encoder_value, relative_encoder_value, v24_pec_diff, pwm - # I2C: i2c_raw14, i2c_angle_deg, i2c_zero_raw14, etc. - # ============================================================================= # Convenience function for external use