You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

520 lines
16 KiB

# 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()