Skip to content

Commit 2820097

Browse files
c0ffee2codeclaude
andcommitted
Add telemetry pipeline with REPL output for PID tuning (M2a pre-hardware)
Expose PID term contributions (last_p/last_i/last_d) for logging, add TelemetryRecorder with pluggable sink (PrintSink now, SdSink when Adalogger arrives), wire into main loop with configurable decimation. Validated on hardware — CSV output confirmed via REPL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2f727da commit 2820097

4 files changed

Lines changed: 89 additions & 8 deletions

File tree

decision/ADR-002-telemetry-logging.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,21 +73,21 @@ Separating the RTC onto its own bus avoids adding traffic to the sensor bus, whi
7373

7474
### Log format
7575

76-
CSV with pipe delimiter for grep-friendliness (consistent with AS5600 diagnostic format):
76+
CSV with comma delimiter (standard CSV):
7777

7878
```
79-
T_RTC,T_MS,ENC_DEG,IMU_DEG,ERR,P,I,D,M1,M2
80-
2026-02-08T14:30:01,12345,+0.5,+0.8,-0.3,2.5,0.1,0.0,165,135
79+
T_MS,ENC_DEG,IMU_DEG,ERR,P,I,D,PID_OUT,M1,M2
80+
12345,+0.5,,+0.5,2.50,0.10,0.00,2.60,303,297
8181
```
8282

8383
Fields:
84-
- `T_RTC` — Wall clock from PCF8523 (ISO 8601, second resolution)
85-
- `T_MS``ticks_ms` since boot (sub-ms precision for inter-sample timing)
84+
- `T_MS``ticks_ms` since boot (sufficient without RTC; `T_RTC` column added as first column when Adalogger hardware arrives)
8685
- `ENC_DEG` — AS5600 angle in degrees (ground truth)
87-
- `IMU_DEG` — BNO085 angle in degrees (when available, empty otherwise)
86+
- `IMU_DEG` — BNO085 angle in degrees (empty until M2)
8887
- `ERR` — PID error term
8988
- `P`, `I`, `D` — Individual PID contributions
90-
- `M1`, `M2` — Motor throttle values
89+
- `PID_OUT` — Raw PID output before clamping to motor range (shows PID saturation)
90+
- `M1`, `M2` — Motor throttle values (integers)
9191

9292
### Write strategy
9393

@@ -109,6 +109,19 @@ SD card root/
109109

110110
Each file gets a header row on creation. RTC timestamp in the first data row provides the wall clock reference; subsequent rows use `T_MS` deltas for precise inter-sample timing.
111111

112+
### Sampling & decoupling
113+
114+
The telemetry pipeline separates data collection from I/O through a facade pattern:
115+
116+
- **`TelemetryRecorder`** — facade called from the main loop. Accepts all telemetry fields per cycle, handles decimation, and delegates output to a pluggable sink.
117+
- **`PrintSink`** — current backend. Prints CSV rows to REPL serial console. No buffering needed.
118+
- **`SdSink`** (future) — buffers N rows in RAM, flushes to SD card file. Plug in when Adalogger hardware arrives — no structural changes to main loop or recorder.
119+
120+
Configurable decimation via `TELEMETRY_SAMPLE_EVERY` constant in `main.py`:
121+
- Set high (e.g., 10000) for REPL output to avoid serial flood
122+
- Set to 1–5 for SD card logging to capture full-rate data
123+
- Decimation is cycle-count based: every N-th call to `record()` emits a row
124+
112125
## Consequences
113126

114127
### Positive

main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from display_pack import (draw_disarmed, draw_arming, draw_ready,
99
draw_stabilizing, draw_error)
1010
from pid import PID
11+
from telemetry import TelemetryRecorder
1112

1213
# =====================================================
1314
# Hardware
@@ -40,6 +41,9 @@
4041
# Display update (every N-th PID cycle to avoid display overhead each loop)
4142
DISPLAY_EVERY = const(5) # 10 Hz display refresh
4243

44+
# Telemetry decimation: 1=every cycle, N=every Nth (high for REPL, lower for SD)
45+
TELEMETRY_SAMPLE_EVERY = const(10)
46+
4347

4448
def clamp(value, lo, hi):
4549
if value < lo:
@@ -59,6 +63,7 @@ def buttons_by_held():
5963
# =====================================================
6064
def main():
6165
pid = PID(kp=5.0, ki=0.5, kd=0.0, integral_limit=200.0)
66+
telemetry = TelemetryRecorder(TELEMETRY_SAMPLE_EVERY)
6267
motors = MotorThrottleGroup([MOTOR1_PIN, MOTOR2_PIN], DSHOT_SPEEDS.DSHOT600)
6368

6469
try:
@@ -82,6 +87,7 @@ def main():
8287

8388
# ----- STATE 4: STABILIZING -----
8489
pid.reset()
90+
telemetry.begin_session()
8591
loop_count = 0
8692
prev_ms = utime.ticks_ms()
8793

@@ -112,13 +118,20 @@ def main():
112118
motors.setThrottle(0, m1)
113119
motors.setThrottle(1, m2)
114120

121+
telemetry.record(
122+
now_ms, angle, None,
123+
angle, pid.last_p, pid.last_i, pid.last_d,
124+
output, m1, m2
125+
)
126+
115127
# Display at reduced rate
116128
loop_count += 1
117129
if loop_count >= DISPLAY_EVERY:
118130
loop_count = 0
119131
draw_stabilizing(angle, m1, m2)
120132

121133
# ----- DISARM -----
134+
telemetry.end_session()
122135
motors.disarm()
123136
motors.stop()
124137

pid.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
class PID:
2+
"""Discrete PID controller with anti-windup and term introspection."""
3+
24
def __init__(self, kp, ki, kd=0.0, integral_limit=200.0):
5+
"""Configure gains and integral windup limit."""
36
self.kp = kp
47
self.ki = ki
58
self.kd = kd
69
self.integral_limit = integral_limit
710
self._integral = 0.0
811
self._prev_error = 0.0
12+
self.last_p = 0.0
13+
self.last_i = 0.0
14+
self.last_d = 0.0
915

1016
def compute(self, error, dt):
17+
"""Return PID output for given error and timestep. Updates last_p/last_i/last_d."""
1118
self._integral += error * dt
1219
if self._integral > self.integral_limit:
1320
self._integral = self.integral_limit
@@ -17,8 +24,12 @@ def compute(self, error, dt):
1724
derivative = (error - self._prev_error) / dt if dt > 0 else 0.0
1825
self._prev_error = error
1926

20-
return self.kp * error + self.ki * self._integral + self.kd * derivative
27+
self.last_p = self.kp * error
28+
self.last_i = self.ki * self._integral
29+
self.last_d = self.kd * derivative
30+
return self.last_p + self.last_i + self.last_d
2131

2232
def reset(self):
33+
"""Zero integrator and derivative state for a fresh control session."""
2334
self._integral = 0.0
2435
self._prev_error = 0.0

telemetry.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
class PrintSink:
2+
"""Output backend that prints CSV rows to REPL serial console."""
3+
4+
def write(self, line):
5+
"""Emit a single CSV line to stdout."""
6+
print(line)
7+
8+
def flush(self):
9+
"""No-op — stdout is unbuffered."""
10+
pass
11+
12+
13+
class TelemetryRecorder:
14+
"""Facade that decimates and formats telemetry rows, delegating I/O to a sink."""
15+
16+
_HEADER = "T_MS,ENC_DEG,IMU_DEG,ERR,P,I,D,PID_OUT,M1,M2"
17+
18+
def __init__(self, sample_every, sink=None):
19+
"""Set decimation rate and output backend (defaults to PrintSink)."""
20+
self._sample_every = sample_every
21+
self._sink = sink or PrintSink()
22+
self._counter = 0
23+
24+
def begin_session(self):
25+
"""Reset counter and emit CSV header. Call when entering STABILIZING state."""
26+
self._counter = 0
27+
self._sink.write(self._HEADER)
28+
29+
def record(self, t_ms, enc_deg, imu_deg, err, p, i, d, pid_out, m1, m2):
30+
"""Format and emit a CSV row every sample_every-th call. Others are silently dropped."""
31+
self._counter += 1
32+
if self._counter < self._sample_every:
33+
return
34+
self._counter = 0
35+
36+
imu_s = "" if imu_deg is None else "{:.2f}".format(imu_deg)
37+
line = "{},{:.2f},{},{:.2f},{:.2f},{:.2f},{:.2f},{:.2f},{},{}".format(
38+
t_ms, enc_deg, imu_s, err, p, i, d, pid_out, m1, m2
39+
)
40+
self._sink.write(line)
41+
42+
def end_session(self):
43+
"""Flush the sink. Call when leaving STABILIZING state (before disarm)."""
44+
self._sink.flush()

0 commit comments

Comments
 (0)