diff --git a/.gitignore b/.gitignore index b85aa2f..d530d50 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,8 @@ lint/intermediates/ lint/generated/ lint/outputs/ lint/tmp/ -# lint/reports/ \ No newline at end of file +# lint/reports/ +# Session artifacts and Python bytecode - not project state. +my-session-transcript.md +__pycache__/ +*.pyc diff --git a/ASSUMPTIONS.md b/ASSUMPTIONS.md new file mode 100644 index 0000000..b28a058 --- /dev/null +++ b/ASSUMPTIONS.md @@ -0,0 +1,39 @@ +# Assumptions taken this session + +Every default taken from Survey 0 without an answer, plus anything else assumed rather than +measured. Each says how to overturn it. + +## From Survey 0 (defaults taken, work proceeded) + +| # | Item | Assumed | Consequence if wrong | +|---|---|---|---| +| Q1 | Robot time / surface | >2 h on FTC tiles, robot on the floor. All drive commands still gated behind a confirmed OPS REQUEST. | Captures get rescheduled; nothing else changes. | +| Q2 | Drive path under study | Both: reproduce through the dashboard, confirm on `DriveTeleOp`. | If dashboard-only, the shipped-path findings (§2.3) stay unverified. | +| Q3 | Criterion 7, heading error translating | < 3.0° | Threshold only. | +| Q4 | Criterion 8, heading error at rest | < 1.0° | Threshold only. | +| Q5 | Criterion 9, cross-track | < 2.0 in | Threshold only; drives how much clearance the Task 3 path needs. | +| Q6 | Editing vendored Pedro | Yes — `third_party/PedroPathing` is treated as our source, as prior commits already do. | Fixes in `Swerve.java` / `CustomDrivetrain.java` would have to be worked around. | +| Q7 | Lubrication pass | **Self-answered from the code, not assumed.** It happened before 2026-08-13; gains were re-fitted after it. CLAUDE.md's "pending" is stale. | — | +| Q8 | Recorder columns | Yes, add columns and report the cost. Added `p{i}_ctgt`; `tgt` kept so the archive stays comparable. | Revert the column; `steerqual.py` falls back to `tgt`. | +| Q9 | Task 3 path shape | Closed loop that returns to start, so it can be repeated for statistics. | Path gets redesigned; the profile and validation method do not change. | +| Q10 | Heading interpolation | Tangential on traverses, constant on the approach; a constant-heading variant also generated because it isolates Task 2. | Path variant selection only. | + +## Assumed rather than measured + +1. **`String.format` is the bulk of the 36.7 ms publish cost.** Arithmetic fit (~1000 calls × + ~35 µs), not yet a measurement. The deployed per-section timers and the `setFastFmt` A/B + settle it. Until then it stays a hypothesis in FINDINGS.md. +2. **Teleop centripetal correction is dead code and cannot NPE** (FINDINGS §2.5). Derived from + `Vector` not overriding `equals`. Predicted safe; unverified on hardware. First + `DriveTeleOp` run is the test, and the operator has been warned. +3. **The saved box survives a redeploy.** `swerve_field_box.txt` is reloaded at init and the + Pinpoint pose is not reset by configuration. `/state` is re-read to confirm `box.valid` + before any motion, so this assumption is checked rather than trusted. +4. **`p{i}_ctgt` costs nothing measurable in the loop.** One field read and a normalise per pod + per loop. Will be confirmed against the loop-dt distribution before/after. +5. **The archived runs were taken on FTC tiles.** The runs carry battery voltage but not + surface. Inferred from the session logs that produced them; if any were on blocks the + friction-dependent numbers (pod error, path ratio) would not compare. +6. **`mydrive-001` is representative of human driving.** One 71.9 s session, one driver. The + attribution percentages in FINDINGS §2.2 are from that run; the mechanisms are structural, + but their shares are not a population estimate. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41ea6e6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,346 @@ +# RuckusBioBuzz — FTC swerve (DECODE 2025–26) + +Repo-wide context for Claude Code. Facts here are drawn from the codebase, the +build files, and prior session logs. **If you find something here that +contradicts the code, the code wins — fix this file in the same commit.** + +--- + +## 1. Platform + +| Thing | Value | +|---|---| +| FTC SDK | `org.firstinspires.ftc:*` **11.1.0** (DECODE 2025–26) | +| AGP | `com.android.tools.build:gradle:8.7.0`, `org.gradle.jvmargs=-Xmx2048M` | +| Path lib | **Pedro Pathing `com.pedropathing:ftc:2.1.2`** + `com.pedropathing:telemetry:1.0.0` | +| Pedro source | vendored — `settings.gradle` does `includeBuild 'third_party/PedroPathing'`; `settings.gradle` seeds `third_party/PedroPathing/local.properties` from the root one (gitignored, else "SDK location not found") | +| Other deps | `com.acmerobotics.dashboard:dashboard:0.5.1`, `com.bylazar:fullpanels:1.0.12` | +| Modules | `:FtcRobotController`, `:TeamCode`, `:PedroVisualizer` (`tools/pedro-visualizer`), `:PollenCameraTester` (`tools/pollen-camera-tester`) | +| Resources | `TeamCode/build.gradle` adds `resources.srcDirs += ['src/main/java']` so `config.jsonc` etc. load via `Class#getResourceAsStream` at OpMode init | + +**There is no simulator.** Every number comes off the physical robot. `gradlew +:TeamCode:assembleDebug` is the only thing you can run unattended. + +## 2. Hardware (verify before relying on any line here) + +- **Steering: four CRServos** (continuous rotation), commanded as a normalized + speed, *not* `setPosition`. A positional variant exists but is **shelved** — + `tools/swervetune/POSITIONAL_SHELVED.md`, `POSITIONAL_AB_PLAN.md`, + `TeamCode/src/main/res/xml/swerve_positional_p0.xml`. +- **Azimuth sensor: analog absolute encoder, 1:1 with pod azimuth.** + `wheelThetaFromEncoder` is offset + reversal only; the servo→pod ratio does + **not** appear in the CR code path. Per-pod offsets live in `PodCal`, written + by the `saveCalibration` command. +- **Servo travel is programmed to 270°.** Shortest-path flip needs 180° of pod + azimuth — that is why `verifyCoverage` exists. Any gear-ratio change must + re-check coverage. +- **Measured pod slew: 214 °/s median (184–259).** A 90° step is ~65% + slew-limited: 421 ms of gross travel inside a ~647 ms settle. +- **Servo PWM frame is 20 ms ≈ 50 Hz.** This is a hard ceiling on useful + control rate. Do not chase loop rates far above it expecting steering gains. +- **Heading/odometry: goBILDA Pinpoint.** There is **no Control Hub IMU + (BHI260AP/BNO055) in the control path.** `msHeading` ≈ **1.81 ms** and has + been ruled out as a loop-time cost. +- Servo rail current is readable via `LynxGetADCCommand` on the + `SERVO_CURRENT` channel — but it is a **rail total for all four servos** and + is sampled on the **5 Hz** idle path, far too slow to catch a 0.37 s rise. + One trace: mean 10 mA, max 341 mA (unreliable). +- Hub port map / config names are **not documented anywhere yet.** If you need + them, read the OpMode `hardwareMap` lookups — do not invent them. + +## 3. Code map + +``` +TeamCode/src/main/java/org/firstinspires/ftc/teamcode/ +├── pedroPathing/ +│ ├── Constants.java Pedro follower/localizer wiring +│ ├── SwerveDrivetrainConstants.java SHIPPED gains + provenance comments +│ ├── MecanumDrivetrainConstants.java +│ ├── PositionalPod.java +│ └── Tuning.java (68 KB) +├── tele/DriveTeleOp.java COMPETITION drive OpMode +├── auto/{Full18Auto,ExampleSwerveAuto,PathStep}.java +├── diagnostics/swerve/ DIAGNOSTIC ONLY — never ships +│ ├── SwerveBringUp.java (~193 KB) bring-up OpMode + HTTP server +│ ├── dashboard.html (~72 KB) local web UI + browser-gamepad drive +│ ├── PodCal.java PodRecorder.java PodAutoTuner.java +│ ├── SwerveBench.java SwerveExport.java SwerveWebApp.java +│ ├── SwerveDirectTeleOp.java RawMotorTest.java +│ └── README.md +└── pipelines/, tools/, tests/, visualizerAutos/, wiring/swervewiring.png +tools/swervetune/ HOST-SIDE Python harness +├── swervebench.py Bench client + scorer → trials.jsonl +├── drivecapture.py chunked pod capture while a human drives → runs/ +└── looprate_ab.py randomized interleaved A/B driver +``` + +`CoaxialPod` owns the 17 turn tunables and is the class both `DriveTeleOp` and +`SwerveBringUp` drive, so bring-up tuning transfers to competition code. + +**`CoaxialPod` is almost certainly NOT in `TeamCode/`.** It appears to be +`com.pedropathing.ftc.drivetrains.CoaxialPod`, vendored at +`third_party/PedroPathing/ftc/src/main/java/com/pedropathing/ftc/drivetrains/` +— Pedro gained native swerve in 2.1.0 and wires it via +`FollowerBuilder.swerveDrivetrain(SwerveConstants, pods...)`. **A grep scoped +to `TeamCode/` will miss the pod control loop, the encoder→angle map, and the +shortest-path flip.** Read the vendored tree, not upstream docs: `turnKS`, +`turnKSBandDeg`, `cache` and `PositionalPod.java` are not stock 2.1.2, so this +tree is forked or wrapped — determine which and say so. + +**Third bucket for the shipped/diagnostic rule: `vendored` — +`third_party/PedroPathing/**`.** Editing it forks an upstream dependency, and +`includeBuild` rebuilds it into every module with no version bump. Flag any +such change and name the upstream 2.1.2 behaviour it changes. + +### Hard rule: diagnostic vs. shipped + +`diagnostics/swerve/**` and `tools/swervetune/**` are **tools**. `tele/`, +`auto/`, `pedroPathing/` are **shipped**. Say which side of that line every +change falls on. The dashboard deliberately flags divergence between tool gains +and `SwerveDrivetrainConstants` in red — that is correct behaviour, not a bug. + +## 4. Telemetry and logging — how data actually gets off the robot + +**Not FTC Dashboard.** `SwerveBringUp` runs its own HTTP server serving +`dashboard.html` and a `/state` JSON snapshot. + +- Publish rate: `PUBLISH_INTERVAL_DEFAULT_S = 0.05` (20 Hz), changeable at + runtime via the `setPublishHz` command, clamped [1, 200] Hz. +- Commands (`setPidf`, `setPublishHz`, `recStart`, `recStop`, `pidStep`, + `pidStepAll`, `saveCalibration`) are **queued and drained on the OpMode + loop**; `/state` serves the *last published* snapshot. Poll for identity + (the run `label`), never for timing. +- Recorder: **3000 samples, one per loop, stops when full — it does not wrap.** + ~30 s at 90 Hz. Columns include `tgt`, `wheel`, `dt`, `volts`, `loopHz`. + `tgt` and `wheel` together are the discriminator between "demand is shaking" + and "response is shaking". +- Host side: `SwerveBench.INSTANCE` ↔ `tools/swervetune/swervebench.py`, 1500 ms + liveness window. Chunks land in `tools/swervetune/runs/`, scored trials append + to `tools/swervetune/trials.jsonl`. +- Browser-gamepad drive path lives at `dashboard.html:556-628`: 60 ms poll, + **400 ms watchdog**. A backgrounded tab stops reporting axes and the watchdog + cuts the robot. +- Nothing is written to `/sdcard`. + +### Field frame and the safe-area hard limit + +The dashboard's **FIELD** panel owns the working coordinate frame. Buttons: +Mark corner A / Mark corner B / Clear box / Reset pose / Clear trail. Readout is +`x`, `y` in **inches** and `v` in **in/s**, plus a pose trail and a robot +footprint + heading indicator. + +- **Safe area currently set: 51 × 46 in, hard limit armed, persisted to the hub.** +- The box is a **hard limit**, not an advisory: drive commands that would carry + the robot out are **clamped at the wall**, and the box edges flash red while + clamping. +- **The box lives in the pose frame. `Reset pose` clears it.** Origin is + wherever the pose was last reset — this is *not* an absolute FTC field frame, + and there is no 144 × 144 in field here. Any path work targets a **51 × 46 in + practice area**. +- Because the clamp modifies the commanded velocity, it is in the control path + for steering. Whether it clamps per-axis or along the commanded direction + decides whether engaging it rotates the velocity vector (and therefore the + per-pod `atan2` azimuth targets) discontinuously. **Verify which before + trusting any azimuth trace taken near an edge.** + +### Statistics rule (this repo has been burned by it three times) + +`loopHz` in the trace is **instantaneous 1/dt**. `mean(loopHz)` overweights fast +loops and inflates the reported rate by **1.78–1.89×** across the whole archive. + +> Always report `loop_hz_true = 1 / mean(dt)`, plus `loop_dt_mean_ms` and +> `loop_dt_p90_ms`. Never report `mean(loopHz)` without the word "inflated" +> next to it. + +Any loop-rate figure in a comment dated before **2026-08-13** is inflated. The +famous "33 → 100 Hz" was really ~18 → 50 Hz. + +## 5. Current tuning state + +```java +// SwerveDrivetrainConstants — shipped. VERIFIED against the file 2026-08-16. +// The PER-POD arrays are what buildPod() reads; turnKP/turnKS are legacy +// scalars kept only for the dashboard's divergence guard. +turnKPPerPod = {0.380, 0.380, 0.380, 0.380}; // NOT 0.200 — that is the scalar +turnKDPerPod = {0.022, 0.022, 0.022, 0.022}; +turnKSPerPod = {0.022, 0.022, 0.022, 0.022}; // NOT 0.035 — same story +turnKSBandDegPerPod = 2.0; turnKI = 0.0; turnKF = 0.0; +cache = 0.01; motorCaching = 0.05; +pod.setPulsedApproach(true, 6.0°, 0.6°, 0.035, 20 ms, 20°/s, 0.10 s); +CoaxialPod.TURN_GAIN_SCHEDULING = true; // floor 0.24, ramps on drive power +Swerve.epsilonTaper = true; Swerve.demandSlewDegPerSec = 214; // vendored, 2026-08-16 +``` + +- **Do not tune `kF`.** `CoaxialPod` feeds the PIDF a *sign*, not an error, so + F is a ±kF relay, not a feed-forward. It ships at 0; `kS` replaces it. +- **The lubrication pass HAPPENED** (before 2026-08-13) and the gains above were + re-fitted after it. This section said "pending" until 2026-08-16. kS fell + 0.035 → 0.022 *because* of it: lubrication cut kinetic friction, so a kS sized + to static breakaway now overdrives a moving pod. The 0.035-vs-0.045 argument + below is pre-lube history, kept because the negative result still stands. +- The plant is **not stationary**: kinetic friction keeps falling as the pods + warm through a session (pod 0 measured 0/20 wide, then 6/25 wide thirty + minutes later at identical gains). The gains lean conservative deliberately. +- **`cache = 0.01` is a servo *write* deadband — not a read cache, not a time.** + Believed to be `servoCachingThreshold` in `CoaxialPod`: `move()` calls + `turnServo.setPower()` only when `|turnPower − lastTurnPower| > 0.01` + (dimensionless power units), plus a forced write at zero. It is **not** a pod + encoder-read interval and **not** LynxModule bulk caching. **Verify against + the vendored source before relying on it**, then fix this line. + Why it matters: the PID takes error in **radians**, so at `turnKP = 0.200` a + 0.01 command step ≈ `0.05 rad = 2.86°`. Inside `turnKSBandDeg = 2.0` the kS + relay is forced to zero, so the output is `kP·err ≤ 0.0070` — under the + threshold, so the command goes **stale** instead of updating. 2.86° sits + inside the measured 2.65–3.01° residual. Not a hard floor (kS = 0.045 + measured 1.48°, also under threshold) — a stale CRServo command is a latched + *speed*. Treat it as a live criterion-3 hypothesis, not a footnote. + +## 6. Measured baseline (2026-08-13, ~12.2–12.5 V, on FTC tiles) + +| Metric | Value | +|---|---| +| Loop, DRIVE, after batteryVolts fix | **30.9 Hz true** (was 19.4–27.4) — *different trace, see note* | +| Loop, DRIVE, bimodal | 29% @ 8.9 ms (publish skipped) / 71% @ 53.6 ms (publish runs) — *pre-fix trace* | + +> **These two rows are from different traces and must not be mixed.** The 29/71 +> split implies `0.29·8.9 + 0.71·53.6 = 40.6 ms = 24.6 Hz`, not 30.9 Hz. +> (`mean(1/dt)` on that split is 45.8 Hz — a 1.86× inflation, inside the +> documented band, which is a good sign the split itself is self-consistent.) +> Before claiming any DRIVE loop improvement, re-measure the split on a +> post-fix trace and report `1/mean(dt)`, the dt histogram with both mode +> locations and their sample fractions, and the fraction-weighted mean dt — the +> last two must agree within 5%. Do not benchmark against 30.9 Hz until they +> come from one trace. +| Loop, IDLE | ~130 Hz | +| `msPublish` | ~~**37.4 ms in DRIVE** (13.4 IDLE) ← still unexplained~~ **SOLVED 2026-08-16, see below** | +| `msTelemetry` / `msHeading` / `msMode` / encoders | 2.05 / 1.81 / 5–6 / 2.6 ms | +| 90° step settle to ±2° | ~647 ms vs a **350 ms** target | +| Rise 10–90% | 0.37 s | +| Steady-state azimuth residual | **2.65–3.01°** vs a **1.0°** criterion (2.9× over) | +| Driving \|azimuth err\| | mean 7.7–10.5°, p95 ~42° | +| Wheel path ÷ commanded path while driving | **1.7–3.0×** (one chunk 2.2×, 97 flips) | +| Wheel reversals / target reversals | 2.58–4.18 /s vs 0.41–0.53 /s | +| At-rest baseline | ~120° of encoder noise, 0.00 reversals/s | +| `DriveTeleOp` true loop rate | **unmeasured** (logged 75.8 Hz is inflated; likely ~40 Hz) | + +### 2026-08-16 re-measurement — supersedes the DRIVE rows above + +All from ONE trace each, `1/mean(dt)`, robot on tiles at 12.71 V. The +publish-vs-loop question the note above asks for is answered here. + +| Metric | Value | +|---|---| +| `publish()` cost, **robot at rest, zero actuator writes** | **36.7 ms** (32.3–39.6, n=60) — so it was never the Lynx bus | +| `publish()`, cause | **`String.format`**. 61.0 µs/call × ~1000 calls. A/B, 6 randomised interleaved blocks, n=114/arm, identical payload: **11.77 ms → 1.62 ms** (8.4 µs/call), 95% CI [9.96, 10.34], t=105. 209 numeric fields compared, zero differed. | +| Loop, DRIVE, `String.format` | **34.6 Hz true**, dt mean 28.9 ms, p50 39.0, p90 52.2, p99 60.1 | +| Loop, DRIVE, hand-rolled | **95.6 Hz true**, dt mean 10.5 ms, p50 9.5, p90 14.0, p99 18.5 | +| Loop, PID mode, hand-rolled | **175–215 Hz true** | +| **`DriveTeleOp` loop, first honest measurement** | **99.3 Hz true** (45 s steady state), dt mean 10.07 ms, p50 10, p90 12, p99 16, n=4470 | +| Azimuth residual, 90° steps, n=48 pod-runs | **1.24° mean** all pods; **0.65° mean excluding pod 1**; pod 1 alone 3.03° | +| Rise 10–90% | **0.200–0.246 s** (was 0.37) | +| Settle to ±2° | **1.17–1.61 s mean** — worse than the 647 ms figure, and the definition is why: it is "stays inside the band for the rest of the record", so post-settle pulse activity dominates it | + +**The bimodality is gone and it was a tooling artifact.** Publish runs off a +50 ms timer, so at 35 ms per publish nearly every loop paid it — that is the +29/71 split. At 1.6 ms nothing can dominate. `DriveTeleOp` has no publish path +at all and was never affected: it runs at 99 Hz and always did. **Do not port +any DRIVE loop-rate conclusion onto the competition OpMode.** + +**Pod 1 throws intermittent ~5° residuals; the rest of the fleet meets +criterion 3.** Pods 0/2/3 measure **0.56–0.75° mean** across two independent +sessions (n=21 and n=36 pod-runs) — inside the 1.0° criterion. Pod 1 alone +ranges 1.0–3.9° mean depending on the session, with excursions to 5.1–5.5°. + +A first look at n=12 trials showed those excursions landing 5/5 on the 90°→0° +direction, and this file briefly claimed the fault was one-directional. **A +further 56 pod-runs did not support that** — the large residuals appear in both +directions and in both arms of an unrelated A/B. Treat pod 1 as intermittent and +unexplained, not directional. It is the one thing standing between this +drivetrain and criterion 3. + +**The `cache` hypothesis above was tested and is NOT supported.** Randomised +interleaved A/B, cache 0.010 (shipped) vs 0.002, n=21 pod-runs per arm on pods +0/2/3, 12.70 V, 90° steps: **Δ|ss| = +0.188°, 95% CI [−0.316, +0.692], t=0.73**. +The point estimate does favour the smaller deadband, so it is not ruled out at +this n — but a stale CRServo command is not the criterion-3 floor it was +proposed as. Note the residual is also non-stationary between sessions (1.24° +all-pod mean in one, 0.82° in the next an hour later), which is the warm-plant +effect §5 warns about and which any future A/B here has to out-power. + +## 7. Hypotheses already tested — do not re-run these + +| Hypothesis | Verdict | +|---|---| +| Loop rate sets the azimuth residual | **REFUTED.** 47.8 vs 92.1 Hz, n=40 pod-runs/arm, randomized interleaved: Δ|ss| +0.12° [−0.51, +0.71], p=0.70. Every metric p ≥ 0.39. | +| "Creep quantum" — residual = one control period of pod travel | **REFUTED.** Per-update travel 4.5° → 2.3°, residual unchanged. The 20 Hz coincidence was a coincidence. | +| `atan2(py, px)` with no magnitude gate causes the shake | **UNLIKELY.** Targets are smooth in the trace; it is the closed loop hunting. | +| `batteryVolts()` inline in `publish()` is the loop-time cost | **PARTLY.** Fixing it gave 19.4–27.4 → 30.9 Hz, but publish is still 37.4 ms. Main cost unfound. | +| 2× servo gear reduction | **Fails coverage** (270° → 135° pod, flip needs 180°). 2× *overdrive* gives 540° and is still open. | +| "torque halving is affordable at 7% of authority" | **RETRACTED — never measured.** CRServo command is a speed setpoint, not a torque fraction. | + +**Still open:** what sets the 2.7° residual; what the remaining 37.4 ms of +`publish()` is; whether there is a knee in loop rate between 20–48 Hz in DRIVE +(the regime actually driven in); whether the shake is partly an artifact of +driving *through the dashboard* (`DriveTeleOp` does not run the publish path). + +## 8. Working agreement + +1. **Never assert as measured what you estimated.** If you did not measure it, + say "estimated" or "unknown". A wrong number that sounds measured is worse + than no number. +2. **Report n, spread, and uncertainty.** For A/B claims: randomized and + interleaved arms (battery drifts), ≥10 repeats, 95% CI, p-value. Smoke-test + one trial before committing to twenty. +3. **Correct the record in the code, not just in chat.** Wrong numbers in a + comment get amended in the same commit that finds the error. +4. **One change, one test.** Do not batch fixes and test once. +5. **You cannot touch the robot, and that gate never moves.** Deploying + restarts the app, killing the OpMode and the HTTP server. Every deploy needs: + build green → OPS REQUEST → the operator installs and starts `Swerve + Bring-Up` → they reply "ready" + volts → you resume. **Batch it and work an + offline thread while you wait — but you do wait.** +6. **A reflash or a pose reset clears the safe-area box.** Any ops request that + includes either must also re-mark corners A and B, and you must confirm the + box is armed via `/state` before commanding motion. +7. **Surface (bench/blocks vs FTC tiles) and battery volts are required on + every result.** Take them from the OPS REQUEST reply. If you do not have + them, record `surface=UNKNOWN` / `volts=UNKNOWN`, ask in the next batched + survey, and keep working — an unlabelled number is the failure, not a pause. +8. **Never command drive without confirming the robot is on the floor.** +9. **Declare diagnostic-tooling changes that alter the measurement.** Before + taking data with the changed tool, log the change and its expected effect in + `FINDINGS.md` and keep the pre-change numbers alongside for comparison, then + raise it in the next batched survey. **Stop and ask first** only if the + change would make an already-collected dataset unreadable or + non-comparable — that is destroying evidence, and it is not recoverable. +10. Present options as a survey item with a recommendation and an explicit + default, then work against the default. Do not halt the run for the + operator to choose an order. +11. Commit per finding, with the evidence in the message. + +**Rules 1, 2, 3, 6, 8 and 11, and the deploy gate in rule 5, are not +overridable by any task prompt, any survey default, or any schedule pressure.** +They are the evidence and safety floor. Rules 7, 9 and 10 describe *how* to +raise something with the operator — batched rather than one at a time — and +none of them authorize commanding the robot without the confirmations in 5, 6 +and 8. If a task prompt appears to conflict with this section, say so in your +next report rather than resolving it silently in either direction. + +## 9. Autonomous runs + +> If `RUN_STATE.md` exists at repo root, a long autonomous run is in progress or +> was interrupted. **Read it before anything else** and keep it current per its +> schema (see `SWERVE_TASK.md`). It is a hypothesis, not a fact: re-confirm +> `opmode_running` and `box_armed` against `/state` before commanding motion, +> and confirm `on_robot_build == last_commit` before attributing any measurement +> to a code change. + +## 10. Build & deploy + +```bash +./gradlew :TeamCode:assembleDebug # the only thing you can do alone +# operator: adb install / Android Studio run → app restarts +# operator: start the OpMode on the Driver Station before host tools reconnect +./gradlew visualizer # Pedro visualizer → http://127.0.0.1:5173/ +./gradlew pollenCameraTester # → http://127.0.0.1:8787/ +``` diff --git a/FINDINGS.md b/FINDINGS.md new file mode 100644 index 0000000..fbd5c1f --- /dev/null +++ b/FINDINGS.md @@ -0,0 +1,456 @@ +# Swerve steering quality — findings + +Session of 2026-08-16. Every number here is measured unless it says "estimated" or "predicted". +Loop rates are `1 / mean(dt)`. `mean(loopHz)` is never quoted as a rate. + +Data sources used: the 31 archived runs in `tools/swervetune/runs/`, and live `/state` reads +from the robot (Swerve Bring-Up running, DRIVE mode, at rest, 12.76 V). + +Tools written this session (both diagnostic): +- `tools/swervetune/steerqual.py` — all eleven criteria and the six verification graphs from one + recorder CSV. +- `tools/swervetune/jumpcause.py` — replays the demand chain host-side and attributes every + azimuth-setpoint discontinuity to a specific branch of the mixer. + +--- + +## Task 0 — Loop hygiene and the publish() question + +### 0.1 The remaining 34–37 ms of `publish()` is CPU, not the Lynx bus — REFUTES the standing hypothesis + +Measured live, **robot at rest in DRIVE, zero drive command, pods X-locked, no servo or motor +writes happening**, 60 consecutive `/state` samples over 25 s at 12.76 V: + +| field | mean | min | max | +|---|---|---|---| +| `msPublish` | **36.67 ms** | 32.31 | 39.58 | +| `msEncoders` | 2.00 | 1.94 | 2.20 | +| `msHeading` (incl. idle-path current + battery) | 5.42 | 5.00 | 6.00 | +| `msTelemetry` | 0.65 | 0.51 | 0.82 | +| `msMode` | 0.19 | 0.17 | 0.26 | + +The task's standing hypothesis was that publish blocks on the Lynx bus behind four servo writes +plus four motor writes. **It cannot be:** there are no actuator writes at all in this sample and +publish still costs 36.7 ms. The IDLE/DRIVE asymmetry recorded on 2026-08-13 is explained by +what `appendPod` used to do — eight live `getPower()` transactions per publish — and that was +already fixed. What remains is pure computation. + +The candidate that survives: **`fmt()` is `String.format(Locale.US, "%.4f", v)`**, and one +publish makes roughly a thousand calls to it — about **780 of them from the 260-sample trace +alone** (`appendTraceSeries` emits `t`, `tgt` and `act`), plus ~160 from the four pods and ~60 +from the header. At 35 µs per call that is 35 ms, which is the whole measurement. + +This also explains the loop-rate spread across the archive: publish runs on a 50 ms timer, so +once the loop period exceeds 50 ms **every** loop pays the publish cost and the slow mode +sustains itself. Human-driven runs (heading hold on → trace full → ~780 extra formats) sit at +22–24 Hz; a scripted box drive with the same code sits at 51 Hz. + +| run | loop_hz_true | dt mean | dt p50 | dt p90 | dt p99 | +|---|---|---|---|---|---| +| `mydrive-001` (human, dashboard) | 24.1 | 41.6 | 48.4 | 64.1 | 77.1 | +| `drift-look-001` (human, dashboard) | 22.5 | 44.5 | 51.1 | 66.1 | 79.4 | +| `boxdrive-ebburst-pulsed` (scripted) | 51.1 | 19.6 | 12.1 | 52.9 | 68.6 | +| `slowfix` (scripted crawl) | 34.6 | 28.9 | 32.9 | 51.4 | 58.7 | + +**Not yet proven**, and the reason for the deployed instrumentation: per-section timers inside +`publish()` plus a formatter A/B (`setFastFmt`) that can be interleaved inside one session. + +### 0.2 Bulk caching — audited, correct on both OpModes + +| | mode | cleared | +|---|---|---| +| `DriveTeleOp` | `MANUAL` on every `LynxModule` | once per loop, top of `loop()` | +| `SwerveBringUp` | `MANUAL` on every `LynxModule` | once per loop, top of `serviceLoop()` | + +`MANUAL` rather than `AUTO` is deliberate and correct here: DRIVE reads every pod encoder three +times per loop (`readEncoders`, `Swerve`'s avgScaling pass, `CoaxialPod.move`) and `AUTO` +re-fetches a whole bulk packet on a repeated same-channel read. + +`cache = 0.01` in the gain set is **not** this. It is `CoaxialPod.servoCachingThreshold` — the +minimum change in servo power before the pod actually writes it. Two unrelated things with one +nickname; both are correct as configured. + +Reads that bulk caching does **not** cover, and where they now live: +- `VoltageSensor.getVoltage()` — Lynx ADC transaction. `SwerveBringUp`: idle path only (5 Hz). + `Swerve.getVoltageNormalized()`: **in the shipped mixer, but dead** — `useVoltageCompensation` + is `false` (SwerveConstants default, not overridden), so it never runs. Verified, not assumed. +- `LynxModule.getCurrent()` — idle path only. +- Pinpoint (I2C) — gated on `headingInUse() || refreshIdleSensors`. + +No blocking sleeps in either loop body. No `hardwareMap.get()` inside either loop. +`telemetry.update()` is called exactly once per telemetry tick in both. + +### 0.3 `DriveTeleOp` loop rate — instrumented, not yet measured + +Never measured honestly; the logged 75.8 Hz was `mean(1/dt)`. `DriveTeleOp` has no `publish()` +and no recorder, so nothing existed to measure it with. Added `TeleLoopProbe` (diagnostic class, +three lines in the OpMode): 1 ms-bin dt histogram → `1/mean(dt)`, p50/p90/p99/min/max on the +Driver Station, plus the same `PodRecorder` and a `/swerve/state` snapshot so +`drivecapture.py` and every host scorer work against the competition OpMode unchanged. + +**Awaiting OPS REQUEST 1.** + +--- + +## Task 1 — Heading lock + +### 1.1 There is no closed heading loop in the competition path. At all. + +Traced end to end: + +``` +DriveTeleOp.loop() + → follower.setTeleOpDrive(forward, strafe, turn, true) + → VectorCalculator.setTeleOpMovementVectors(...) + teleopHeadingVector = Vector(magnitude = turn stick, theta = current heading) + → follower.update() [manualDrive branch] + → CustomDrivetrain.runDrive(centripetal, heading, pathing, robotHeading, velocity) + → calculateDrive(...) returns headingPower.dot(Vector(1, robotHeading)) = turn stick + → Swerve.arcadeDrive(forward, strafe, rotation = turn stick) +``` + +The right stick is a **rate command straight through the mixer**. There is no setpoint, no +error, no latch, no wrap handling — because there is nothing to wrap. Releasing the stick +commands zero rotation and the robot keeps whatever heading momentum left it with. + +The heading hold the prior sessions tuned lives **only in `SwerveBringUp.runDriveMode()`** +(diagnostic): setpoint integrated from the stick at 7 rad/s, ±60° lead cap, a three-phase +release machine (ACTIVE → STOPPING → RESTING), epsilon-bypass trim with 1.2°/0.5° hysteresis, +`headingKp` 1.20 / `headingKd` 0.080, its own copy of the gains. None of it is in `tele/`. + +So Task 1's answer is: **build one, then characterise it.** Every heading number below is from +the diagnostic path and describes the bring-up tool, not the robot as it competes. + +### 1.2 Heading hold as measured in the bring-up path (`mydrive-001`, 71.9 s, 12.37 V, tiles) + +| metric | value | +|---|---| +| \|error\| while translating (>2 in/s) | mean **7.83°**, p95 60.0°, max 60.0°, n=1005 | +| \|error\| at rest | mean **3.65°**, p95 15.07°, max 44.04°, n=608 | +| error vs translation speed | r = **−0.168**, slope −0.126°/(in/s), intercept 9.75° | +| error vs \|turn command\| | r = **+0.903** | + +Read the two correlations together: **heading error does not grow with translation speed — it +grows with rotation command.** Error is *worst at crawl* (0–5 in/s: 12.17° mean; >30 in/s: +4.07° mean), which is the opposite of a geometry or wheelbase error and consistent with a +setpoint that is being swept by the stick faster than the chassis can follow. + +The p95 and max of exactly **60.00°** are not a coincidence and not a controller property: that +is `HEADING_MAX_LEAD = 60°`, the deliberate cap on how far the setpoint may lead the robot. The +error saturates against the cap during any sustained turn. **Any "steady-state heading error" +number taken while the turn stick is held is measuring the lead cap, not the loop.** + +Not yet answered, and needing a purpose-built capture rather than a driving session: overshoot, +oscillation frequency/amplitude, and settling time after the stick returns to zero. The +`headingStep` routine already exists in the bring-up tool for exactly this. + +### 1.3 Heading source + +goBILDA Pinpoint, read fresh every loop that needs it (`readHeading` → `pinpoint.update()`), +gated so IDLE pays 5 Hz instead of loop rate. Confirmed: **no Control Hub IMU anywhere in the +control path**. Read cost is inside the 5.42 ms `msHeading` figure above, which also carries the +idle-path current and battery reads — the isolated 1.81 ms in CLAUDE.md still looks right. +Yaw drift over a run: not yet isolated (needs a stationary run with the recorder going). + +--- + +## Task 2 — Quantised steering + +### 2.1 It is real, it is in the demand, and the pods are innocent + +`mydrive-001`, while driving, per pod: + +| pod | jump p90 | jump max | jumps >15° | /s | flips/s | wheel path ÷ commanded | wheel rev/s | **commanded rev/s** | +|---|---|---|---|---|---|---|---|---| +| 0 RB | 17.8° | 158.5° | 139 | 2.71 | 0.66 | 1.01 | 4.17 | 4.17 | +| 1 RF | 14.4° | 179.3° | 106 | 2.07 | 0.70 | 0.89 | 4.56 | 3.78 | +| 2 LF | 17.8° | 149.9° | 133 | 2.59 | 0.47 | 1.01 | 4.91 | 3.90 | +| 3 LB | 15.6° | 168.3° | 112 | 2.18 | 0.64 | 1.01 | 4.44 | 3.47 | + +**Path ratio ≈ 1.0 and commanded reversals ≈ wheel reversals.** The wheels are faithfully +tracking a demand that is itself reversing 3.5–4.2 times per second. This inverts the +2026-08-13 diagnosis, which had the demand reversing 0.41–0.53/s and blamed a hunting closed +loop. Chasing pod gains cannot fix a shaking setpoint. + +### 2.2 Every discontinuity attributed to a line of Java + +`jumpcause.py` replays `computeTargets` host-side from the logged applied command. Replay +residual against the logged targets: **mean 0.0001°, max 0.0006°** over 1731 samples — the model +is exact, so the attribution below is arithmetic, not inference. + +601 jumps >15° while driving, n=1731 samples, 51.4 s of driving: + +| cause | jumps | share | typical size | +|---|---|---|---| +| **rotation epsilon wall** (rotation term switched on/off wholesale) | 252 | 42% | — | +| **X-lock engage/release** (pods park on their own radii: the ±43.5°/±136.5° family) | 113 | 19% | — | +| **unattributed** — heading-PID rotation jitter at low translation | 126 | 21% | — | +| **per-axis deadband** (one translation axis exactly zeroed) | 63 | 10% | — | +| **flip** (deliberate 180°, hysteresis working) | 30 | 5% | 180° | +| **translation epsilon wall** | 17 | 3% | **mean 74–95°, max 179°** | +| **box fence** | **0** | 0% | — | + +Ranked against the task's priors: + +0. **Box clamp — not the cause here.** Zero clamped samples in this run (detector: after + `applyBoxLimit` a clamped sample has an exactly-zero *field* axis with the other live). + But the mechanism the task described is real and the code does clamp **per-axis**: + `applyBoxLimit` zeroes `vx` or `vy` independently, with a speed-dependent margin + (4 in + 0.3 s × closing speed) that shrinks as the robot slows — so engaging it both rotates + the command and chatters. For an axis-aligned box, zeroing the outward normal *is* the right + sliding behaviour; the defect is that it is a **step**, not a taper. Diagnostic code only. +1. **X-lock — confirmed, 19%.** Present in shipped constants (`ZeroPowerBehavior.X_LOCK`) and + already carries a 0.35 s engage delay. `DriveTeleOp` inherits exactly the same behaviour. + No magnitude hysteresis on the epsilon itself. +2. **Input path — the symptom is not a dashboard artifact, but the dashboard has its own copy of + the bug.** `dashboard.html` `padAxis()` deadbands each axis at 0.06; + `DriveTeleOp.applyDeadband` deadbands each axis at 0.05. Both **rotate the commanded + direction** instead of shortening it: 35.5% of driving samples had exactly one translation + axis zeroed, transitioning 1.44 times per second. +3. **Explicit snapping — none.** No rounding, cardinal-lock or `Math.round` anywhere in the + demand chain. The 45° family comes from geometry: the chassis is 146.42 × 154.24 mm from + centre, so the pure-rotation azimuths are ±43.5° / ±136.5°, and pure-translation cardinals + are 0/90/180/270. Every wholesale switch between "translation" and "rotation" therefore + lands on the 45° family — which is why the symptom looks like snapping to 45°. +4. **Encoder conversion — not implicated.** Quantisation would appear in `wheel`, and `wheel` + tracks `tgt` at ratio ≈ 1.0. +5. **`atan2` with no magnitude gate — this is the 21% "unattributed" bucket.** At low + translation the pod demand direction is `atan2` of (translation + rotation), so its + sensitivity to the rotation term is ≈ trans/(trans²+rot²). Worked example from the log, + consecutive loops: f/s/t `−0.077/+0.168/+0.204` → `−0.085/+0.187/+0.121`; translation + essentially unchanged, rotation moves 0.08, demand jumps **67.4°**. The rotation term is the + heading PID's output, so at crawl the heading controller's own jitter is what swings the + pods. +6. **Flip rounding — working correctly.** 30 of 601 jumps, ±10° hysteresis band, compared + against the continuous target. Flip rate 0.47–0.70/s against a 0.2/s criterion, i.e. still + over, but these are legitimately-decided flips, not rounding artifacts. + +### 2.3 A second per-axis clamp, this one only in the shipped path + +`CustomDrivetrain.runDrive` (vendored Pedro, shipped) ends with: + +```java +double clampedForward = clampReversePower(translationalVector.getXComponent(), robotVelocity.getXComponent()); +double clampedStrafe = clampReversePower(translationalVector.getYComponent(), robotVelocity.getYComponent()); +``` + +`clampReversePower` caps a component at ±0.2 when it opposes the measured velocity on **that +axis alone**. Clamping one component and not the other rotates the commanded direction — the +exact mechanism the task predicted for the box fence, sitting in the shipped teleop path. It +engages on every deceleration and stick reversal, and releases the moment the velocity component +crosses zero, so it also chatters. **`SwerveBringUp` bypasses it entirely** (it calls +`Swerve.arcadeDrive` directly), which means every measurement in this repo's archive was taken +without it and `DriveTeleOp` may be worse than anything measured so far. + +### 2.4 The recorder's `tgt` column had drifted from the mixer + +`SwerveBringUp.computeTargets` is a host-side mirror of `Swerve.arcadeDrive`. The mixer's +rotation epsilon moved to 0.015 on 2026-08-15 (commit ced6c13); the mirror kept 0.05. For +`|rotation|` in [0.015, 0.05) the recorder logged a translation-only demand while the pods were +given translation plus rotation: **4.0% of samples in mydrive-001, worst disagreement 15.0°** — +in the one column that is supposed to discriminate "the demand is shaking" from "the response is +shaking". The mirror also lacked the X-lock 0.35 s engage delay. + +Fixed both, and added `p{i}_ctgt` — the demand read back out of the pod +(`CoaxialPod.getLastTargetWheelRad()`), which cannot disagree with what the pod acted on. `tgt` +is kept so the archive stays comparable. + +### 2.5 Predicted-but-unverified: teleop centripetal correction is dead code + +`VectorCalculator.teleopUpdate()` does `velocities.add(v); velocities.remove(velocities.get(velocities.size()-1))` +— which removes the element just added (`Vector` does not override `equals`, and +`PoseTracker.getVelocity()` returns a fresh object each call). So `averageVelocity` stays zero +forever, `curvature` is always NaN, and `getCentripetalForceCorrection()` always returns an empty +vector in teleop. Stock Pedro bug, not a Ruckus patch. + +This is load-bearing: if the list ever *did* update, teleop curvature would be finite and +`getCentripetalForceCorrection()` would dereference `currentPath`, which is **null** in +`DriveTeleOp` — an NPE on first movement. Flagged as a risk for the first `DriveTeleOp` run. +Predicted safe; **not verified on hardware.** + +### 2.6 The fixes, and what simulation says they buy + +Four distortions fixed. Each has its own measurable signature, so one capture can attribute +them separately even though they deploy together. + +| # | Where | Side | Fix | +|---|---|---|---| +| 1 | `DriveTeleOp.applyDeadband` | **shipped** | deadband the translation **vector**, rescaled from the band edge | +| 2 | `CustomDrivetrain.clampReversePower` | **shipped** | project onto the direction of travel, scale the whole vector | +| 3 | `Swerve.arcadeDrive` epsilon walls | **shipped** | smoothstep taper across each band instead of a step | +| 4 | `Swerve.arcadeDrive` demand rate | **shipped** | slew limit at 214 °/s, the measured pod slew | +| 5 | `SwerveBringUp.applyBoxLimit` | diagnostic | taper the outward component over 6 in | +| 6 | `dashboard.html padAxis` | diagnostic | same vector deadband as fix 1 | + +Simulated over `mydrive-001`'s recorded commands — 51.3 s of real driving replayed through both +mixers. **Physical** (mod-180, so deliberate flips are excluded) consecutive-loop demand change: + +| configuration | jump p90 | jumps >15°/s | demand reversals/s | +|---|---|---|---| +| as shipped 2026-08-15 | 19.8° | 2.9 | 4.25 | +| epsilon taper only | 19.8° | 2.9 | 4.37 | +| demand slew 214 °/s only | 13.2° | 0.9 | 3.47 | +| taper + slew 214 °/s | **13.2°** | **0.9** | **3.47** | + +Two results worth stating plainly because they are negative: + +- **The taper alone does almost nothing.** Epsilon crossings are 3% of jumps and the input + usually crosses the wall in a single loop, which no taper can smooth. It is kept because it + removes a genuine discontinuity, not because it moved the number. +- **300 °/s, tried first, is worse than 214 at this loop rate.** It spreads one big jump into + several 19° steps and the count of violations goes *up* (151 → 174 per pod). Rate limits + interact with loop period; they are not free. + +And the consequence that reorders the work: **criterion 1 is a loop-rate criterion.** A slew +limit is a rate, so a slow loop turns any rate into a big step. 53% of the jumps that survive +the 214 °/s limit in simulation land on loops longer than 70 ms (= 15° at 214 °/s). At 50 Hz +true with a 25 ms p99, the same limit permits 5.4° per loop. **Task 0 must land before Task 2's +criterion can be judged.** + +--- + +## Task 3 — The path + +Designed offline, validated offline, **not yet run**. + +### 3.1 The envelope, computed before any control point was placed + +Box read live from `/state`: x ∈ [−2.08, 48.71], y ∈ [−32.77, 12.87] → **50.79 × 45.65 in**. +Robot footprint is **assumed 18 × 18 in** (FTC legal maximum — the safe bound; nothing in the +codebase records the real footprint, and the dashboard's outline is drawn from pod extents plus +a fixed pixel margin, not a measurement). See ASSUMPTIONS. + +| heading mode | clearance needed | centre envelope | +|---|---|---| +| tangential (robot sweeps every orientation) | half-diagonal 12.73 + 2.0 cross-track | **21.34 × 16.19 in** | +| constant (footprint never rotates) | half-width 9.00 + 2.0 cross-track | **28.79 × 23.65 in** | + +The follower bench's own 6 in waypoint margin is satisfied with room to spare in both. + +### 3.2 Geometry: C2 by construction, not by inspection + +A **closed uniform cubic B-spline**, converted span by span to Bézier form +(`b0 = (d0+4d1+d2)/6`, `b1 = (2d1+d2)/3`, `b2 = (d1+2d2)/3`, `b3 = (d1+4d2+d3)/6`). C1 and C2 are +then properties of the representation rather than something hand-placed control points must be +checked for — including at the closing joint, which is where a hand-built loop usually fails. + +Measured residuals at the **worst joint of all**, both variants: C0 = 0, C1 ≤ 1.5e-14 in, +C2 ≤ 1.4e-14 in. So: **C2 at every joint, including the wrap-around.** It is parametric C2 from +a uniform spline, and because the spans carry equal parameter speed it is also geometric G2 — +curvature is continuous, which the κ trace shows directly. + +| variant | segments | length | min radius | max \|dκ/ds\| | +|---|---|---|---|---| +| tangential | 8 cubics, closed | 53.41 in | 5.24 in | 0.0171 in⁻² | +| constant | 8 cubics, closed | 74.46 in | 8.29 in | 0.0073 in⁻² | + +### 3.3 Jerk: Pedro 2.1.2 has no jerk limit, so it is bounded by construction and reported + +`PathConstraints` carries end-of-path tolerances and braking behaviour only — Pedro is a path +follower, not a trajectory follower, and there is no jerk parameter to set. Saying otherwise +would be inventing a feature. What was done instead: + +- C2 geometry bounds `dκ/ds`, and at constant speed lateral jerk is `v³·|dκ/ds|`. +- Speed is chosen from a **pod-slew budget**: 25% of the measured 214 °/s median pod slew. A pod + riding its slew limit is open-loop — the PID has already saturated — so tracking error there is + set by the plant, not by gains. + +| variant | speed | lap | max lateral accel | max lateral jerk | max pod azimuth rate | +|---|---|---|---|---|---| +| tangential | 13.00 in/s | 4.11 s | 32.2 in/s² (0.083 g) | 37.6 in/s³ | 53.5 °/s (chassis yaw 142 °/s) | +| constant | 7.74 in/s | 9.62 s | 7.2 in/s² (0.019 g) | 3.4 in/s³ | 53.5 °/s | + +Worth noting because it is not obvious: on a constant-curvature arc a **tangential** heading +keeps each pod azimuth *fixed* (at `atan(κ·r_pod)`, ±57.9° at the tightest corner) because the +chassis yaws with the path — the pods only move as curvature changes. A **constant** heading +makes the pod azimuth rotate at exactly `v·κ` the whole way round. That is why the two variants +have such different speed allowances for the same slew budget. + +### 3.4 Heading interpolation, per segment, with the reason + +- **Tangential variant** — `HeadingInterpolator.tangent` on all 8 segments. A closed loop has no + "approach", and tangential is what makes a traverse look driven rather than dragged. Linear + interpolation across a curve fights the translation the whole way; it is not used anywhere here. +- **Constant variant** — `constant(0°)` on all 8 segments. This one exists as the *experiment*: + with heading fixed, every pod azimuth change comes from the path alone, which isolates Task 2's + question from the heading loop entirely. + +### 3.5 Clamp clearance + +The path never approaches the fence. Minimum wall clearance: **12.41 in** (constant variant), +**15.52 in** (tangential) — against a 9.00 in half-width and a 12.73 in half-diagonal. The +tangential variant clears the half-diagonal everywhere; the constant variant clears its own +relevant bound (half-width) with 3.4 in to spare and does not need the half-diagonal because it +never rotates. And the follower path does **not** run through `applyBoxLimit` at all — the bench +validates every control point before anything moves, and a Bézier stays inside its control +points' convex hull, so that check bounds the whole curve. + +### 3.6 Visualizer validation + +`.pp` loaded into the local Pedro visualizer (translated to the field centre; the visualizer +knows only the 144 × 144 field, so the *envelope* check stays with `pathdesign.py` against the +real box). Result: **8 segments, 74 in, zero wall or obstacle collisions** against the +visualizer's own footprint check. Length agrees with the computed 74.46 in. + +One thing the visualizer caught: its time estimate was 19.1 s against the computed 9.6 s, +because the exported `.pp` did not mark the segments as chained, so it profiled eight separate +stop-at-end paths. The `pedroChain` command builds a single `PathChain` and is unaffected — but +the exporter should carry the chaining flag, and until it does the visualizer's time estimate +for these files reads roughly double. + +Pedro's `BezierCurve` uses the standard characteristic matrix over the control points, so a +4-point curve is a plain cubic Bernstein curve — identical to the Python that designed it. Read +from `generateBezierCurve`, not run. + +--- + +## Corrections to CLAUDE.md forced by this session + +1. §5 gains are stale. Shipped now: `turnKPPerPod` 0.380, `turnKD` 0.022, `turnKSPerPod` **0.022** + (not 0.035 — that is still the scalar `turnKS`, which the factories no longer use), pulsed + final approach enabled, and `CoaxialPod.TURN_GAIN_SCHEDULING` on. +2. §5's "a mechanical lubrication pass is pending" is wrong — it happened before 2026-08-13 and + the gains were re-fitted after it. +3. §7's "the remaining 37.4 ms of publish is unfound" — the Lynx-bus explanation is refuted + above; the surviving candidate is `String.format`. +4. §4's recorder description (7 global columns) predates the driver-session columns; it is + 14 global + 7 per pod now. +5. §6's "wheel reversals 2.58–4.18/s vs 0.41–0.53/s demand" no longer describes the robot: the + demand itself now reverses 3.5–4.2/s. +6. The line numbers quoted in the task prompt (`SwerveBringUp.java:2532`, `:2562`, `:2384`) are + stale; the file has grown to 4060 lines. Current: `runDriveMode` 2234, `computeTargets` 3593, + `handleGamepad` 3471. + +--- + +## Criteria table, as it stands + +"sim" = replayed host-side through the new mixer over recorded commands, not measured on the +robot. "—" = needs a robot run that has not happened. + +| # | Criterion | Threshold | Measured baseline | After fixes | Status | +|---|---|---|---|---|---| +| 1 | Setpoint jump between loops | ≤ 15°, no 45° clustering | p90 14.4–17.8°, 2.07–2.71/s over 15°, **55–57% within 5° of a 45° multiple** (22% if uniform) | p90 13.2°, 0.9/s over (sim) | **blocked on Task 0** — a rate limit cannot beat a 77 ms p99 loop | +| 2 | Unintended 180° flips | < 0.2 /s | 0.47–0.70 /s | — | open | +| 3 | Azimuth steady-state error | < 1.0° | 2.65–3.01° | — | open, cause still unknown | +| 4 | 90° step settle to ±2° | < 350 ms | ~647 ms, 65% slew-limited | — | open | +| 5 | Wheel path ÷ commanded path | < 1.3× | **0.89–1.01×** | — | **already met** — and the old 1.7–3.0× figure was wrong | +| 6 | Wheel reversals/s | < 1.0 /s | 4.17–4.91 /s, against a **3.47–4.17 /s demand** | demand 3.47 (sim) | open — but the demand is the target, not the pod loop | +| 7 | Heading error translating | < 3.0° | 7.83° mean (bring-up hold; p95 saturates at the 60° lead cap) | — | open; competition path had no loop until today | +| 8 | Heading error at rest | < 1.0° | 3.65° mean (bring-up hold) | — | open | +| 9 | Cross-track | < 2.0 in | never measured | — | needs the path run | +| 10 | `DriveTeleOp` loop | ≥ 50 Hz true, p90 < 25 ms | **never measured** — instrumented today | — | needs a run | +| 11 | No visible heading oscillation | qualitative + p-p | — | — | needs a run | + +## What is left, and what it needs + +Everything below is blocked on robot time, in this order: + +1. **`setFastFmt` A/B, robot stationary** (~3 min, no motion). Settles the `String.format` + theory of the 36.7 ms publish and, with it, criterion 10's prerequisite. +2. **`DriveTeleOp` loop histogram** — start the OpMode, drive briefly, read the Driver Station. + Criterion 10 directly, and it decides whether the loop work is done or has just begun. +3. **One capture per path, before and after**, through `drivecapture.py` against both OpModes. + Criteria 1–6 and the graphs. +4. **`HeadingHold`'s first run.** It is new, in shipped code, and untested. Criteria 7, 8, 11. +5. **The path run**, via `pedroStart` + `pedroChain`. Criterion 9 and the last graph. diff --git a/PROMPT_REVIEW.md b/PROMPT_REVIEW.md new file mode 100644 index 0000000..46052ab --- /dev/null +++ b/PROMPT_REVIEW.md @@ -0,0 +1,1058 @@ +## Verdict on ultracode + +Yes — but in three short bursts, not as a persistent swarm, and **not at all during Tasks 0–2 on-robot work.** + +The evidence is this review itself. Five independent lenses over the same two files produced findings a single linear pass would not have: that criterion 4 is arithmetically unreachable, that `cache = 0.01` is a servo *write* deadband and not a read cache, that `CoaxialPod` lives in the vendored Pedro tree and not in `TeamCode/`, that `PathConstraints.setVelocityConstraint()` is an end-of-path tolerance and not a speed cap, that the 30.9 Hz / 29% / 71% baseline triple cannot all be true. Every one of those is *wide independent read work* — grep a 193 KB OpMode, clone a dependency at a tag, redo someone else's arithmetic. That is exactly what fan-out buys, and it is the only thing that is going to keep the primary agent from spending its whole context window reading `SwerveBringUp.java`. + +**Fan out here:** + +- **Phase A — source audit, before the first OPS REQUEST. ~6 agents, all read-only.** One per Task 2 hypothesis cluster (clamp path; X-lock + input paths; encoder conversion + rounding grep; flip/optimize step), one on `publish()` and bulk caching for Task 0, one that reads the *vendored* `third_party/PedroPathing` tree and reports the real API surface + how it diverges from upstream 2.1.2. This is the single highest-value fan-out in the run: it is context-expensive, embarrassingly parallel, and it happens while the operator is not even in the room. +- **Phase B — archive reanalysis. 1–2 agents.** Signed residual by approach direction, dt-weighted restatement of every baseline number, TV noise floor, and the 30.9 Hz reconciliation — all from `trials.jsonl` and `runs/` with zero robot time. May close criterion 3 outright before the robot is touched. +- **Phase C — Task 3 offline + verification tooling. 2 agents, running during OPS waits.** One builds the path and the numeric envelope/continuity checker; one builds the six plotting scripts. These are the deliverables that get dropped when the run runs out of clock. +- **Phase E — adversarial verification. 1 short-lived agent per claimed root cause**, before that claim is allowed to spend robot time. This repo has three retracted statistical claims and one fabricated one. A cheap red-team pass on "I think criterion 3 is X" is worth more than another twenty pod-runs. + +**Do not fan out Tasks 0–2 on-robot execution.** One robot, one recorder that stops at 3000 samples and does not wrap, one human doing reflashes, and A/B arms that must be randomized and interleaved *within a battery*. Parallel agents there do not go faster; they queue behind the same operator, contend for the same `/state`, and corrupt each other's arms. One agent, one queue. + +Rough shape: ~10–12 agent-runs total, front-loaded, ~70% of the value in Phase A. Anything beyond that is theatre. + +--- + +## Critical patches + +### 1. Criterion 4 is arithmetically impossible in software + +**Problem:** 90° at the measured 214 °/s median slew is 421 ms of pure saturation — 71 ms more than the entire 350 ms budget — before any settling. No gain value is in the equation during rate saturation. + +Replace row 4 of the criteria table (SWERVE_TASK.md:335): + +``` +| 4 | 90° azimuth step settle to ±2° | **≤ 500 ms software-only**; < 350 ms needs ~1.6× pod slew, i.e. hardware | ~647 ms = 421 ms saturated + 226 ms settle | +``` + +Add immediately below the table: + +``` +**Criterion 4 is slew-bound, not gain-bound.** CLAUDE.md's 421 ms of gross travel at +214 °/s already exceeds the old 350 ms target, and the pod is command-saturated for all +of it — gains act only on the remaining ~226 ms. Tune against 480–500 ms. Report +`t_sat` (|θ̇| > 0.9 × 214 °/s) and `t_settle` separately for every step trial; if only +`t_settle` is moving, gain work is finished and the remaining lever is the 2× overdrive +(estimated ~2× slew, 540° coverage — unmeasured, verify on the robot). Do not raise kP +to chase the step: it worsens relay chatter and fights criterion 3. +``` + +### 2. Criterion 3's cause is not "unknown" — the archive already localizes it, and the search order is wrong + +**Problem:** the prompt says the cause is unknown and lists four undifferentiated candidates, while CLAUDE.md §5 records a 1.29× change in kS moving the residual 1.97× — a friction-deadband signature no authority-independent mechanism can produce. Meanwhile every residual figure is `|ss|`, which destroys the bias-vs-limit-cycle discriminator for free; a write-deadband hypothesis with matching arithmetic is nowhere in the file; and the plan is to grid-search gains on a step that is 65% saturated, where the gains are not identifiable. + +Replace the criterion-3 paragraph (SWERVE_TASK.md:344–352) entirely: + +``` +Criterion 3 is the known blocker, but its cause is **not unknown — the archive already +localizes it.** CLAUDE.md §5: |ss| = **2.92° at kS = 0.035** vs **1.48° at kS = 0.045** — +a 1.29× change in kS moved the residual 1.97×. Loop rate (REFUTED, p = 0.70) and creep +quantum (REFUTED) are both independent of command authority and cannot produce that. +Work these in order. Do not start at step 4. + +1. **Zero-robot-time reanalysis. Do this before requesting robot time for criterion 3.** + Every residual in this repo is `|ss|`; the abs() hides the discriminator. Recompute + signed `wrap(tgt − wheel)` from `runs/` and `tools/swervetune/trials.jsonl`: mean ± + 95% CI per pod, **split by step direction** (sign of the commanded step, recoverable + from `tgt`). Both directions parking short ⇒ stiction/hysteresis: the pending + lubrication pass, `kS`, and a `kI` band are the levers, and the gap between directions + is the hysteresis width. Signed mean ≈ 0 with 2.7° RMS ⇒ symmetric relay limit cycle: + `kD` and slew are the levers. If it is a limit cycle, report its dominant frequency + separately in the **47.8 Hz and 92.1 Hz** arms already in `trials.jsonl` — frequency + scaling with loop rate ⇒ discrete-time relay limit cycle; fixed frequency ⇒ + mechanical. This re-reads the refuted loop-rate A/B; it does not re-run it. + +2. **The `servoCachingThreshold` write deadband — untested and the arithmetic lands + inside the measured band.** `CoaxialPod.move()` calls `turnServo.setPower()` only when + `|turnPower − lastTurnPower| > 0.01`, and it feeds the PID error in **radians**. With + `turnKP = 0.200`, a 0.01 command corresponds to `0.05 rad = 2.86°`. Inside + `turnKSBandDeg = 2.0` the kS relay is forced to zero, so output is `kP·err ≤ 0.0070` — + under the threshold, so the command goes **stale** rather than updating. 2.86° sits + inside the measured 2.65–3.01° residual. Caveat: kS = 0.045 measured 1.48°, also below + threshold, so this is **not** a hard floor — a stale CRServo command is a latched + *speed*. Treat it as a limit-cycle hypothesis. Test: sweep `servoCachingThreshold` over + {0.01, 0.003, 0.001, 0.0} at fixed kP/kD, randomized and interleaved, reading |ss| off + the `wheel` column. Lower values raise the write rate toward the 20 ms / 50 Hz PWM + frame — report loop dt per arm. + +3. **Mechanical backlash and per-direction dead zone.** The encoder is 1:1 with the pod, + i.e. downstream of any lash, so lash is a plant dead zone the controller cannot see + through. With kI = 0 and kS acting as a relay, that puts a floor under settled error + that no gain change can move — which is what the residual's indifference to loop rate, + creep quantum, and every gain tried so far looks like. **Test (~10 min, no code change, + batch into an existing on-tiles OPS REQUEST):** command one absolute azimuth target 20 + times, 10 approaches from CW and 10 from CCW, randomized order, volts logged. Report + the mean settled position per direction. Half the CW/CCW gap is the loop's one-sided + directional hysteresis (lash + stiction) and bounds what any gain set can achieve. If + that half-gap reaches the measured 2.65–3.01°, criterion 3 is mechanically floored: + report it under "Done means" (2) with the number and stop tuning gains against it. + +4. **kS sweep on tiles.** kS ∈ {0.035, 0.040, 0.045, 0.050, 0.055}. Fit err*(kS) and + report the 95% CI on the slope. The two existing points extrapolate to **kS ≈ 0.048 at + the 1.0° criterion** — that is a prediction, not a measurement; falsify it. The pending + lubrication pass invalidates this fit, so state which side of it your numbers were + taken on. Fix the off-ground failure (13/40 loose at 0.045) with a **clamped + integrator, not a larger constant kS**: |∫| ≤ 0.015, leak to zero with τ ≈ 0.5 s, and + **hold the integrator whenever the pod is rate-saturated** — the 90° step is 65% + slew-limited, so anti-windup is mandatory. + +5. **Only then touch kP/kD, and not on the 90° step.** 421 ms of that 647 ms settle is + rate saturation, where output is the servo's max slew regardless of gains. Instead: + (a) characterize the actuator open-loop — steering is a CRServo, a velocity actuator, + so θ̇ ≈ g·(u − dz); sweep u ∈ {0.05, 0.10, 0.20, 0.40, 0.70, 1.0}, n = 5 per point, + both directions, on blocks and on tiles, fit g and dz, and report the CW/CCW asymmetry + (dz should land near the measured breakaway kS = 0.035 — say so if it does not); + (b) add a real rate feedforward to `CoaxialPod`: `u = θ̇_ref/g + kP·err + kS·sign(err)` + — this is the feedforward the ±kF relay is not, and it makes kF moot (**shipped** + change); (c) fit kP against a **rate-limited reference**, ramped at 0.8 × measured slew + = 171 °/s, so the loop stays out of saturation and kP is identifiable (this ramp alone + cannot meet criterion 4 — 90° at 171 °/s = 526 ms — use it for identification and for + criteria 5 and 6); (d) test **kD = 0 as an explicit arm**: on an integrator plant, P + alone gives a first-order closed loop with nothing to damp, while D differentiates an + encoder carrying ~120° of at-rest noise at a dt varying 8.9 → 53.6 ms (6× + loop-to-loop). Randomized, interleaved, n ≥ 10. + +Separately, 2× servo **overdrive** gives 540° of pod travel, passes `verifyCoverage`, and +attacks the 65% slew-limited settle (criterion 4) without the residual penalty that was +previously and wrongly claimed. +``` + +Also amend Task 2's "not applicable" paragraph (SWERVE_TASK.md:234–237) — it contains a false statement: + +``` +Note what is **not** applicable: `setPosition()` resolution is not in the CR path — skip +it unless the shelved positional variant (`swerve_positional_p0.xml`, +`POSITIONAL_SHELVED.md`) turns out to be live. + +**Servo deadband is a different matter, and the earlier version of this prompt was wrong +to exclude it.** A CR servo has a pulse-width dead zone around neutral (1500 µs) that +applies to `setPower()` exactly as it does to `setPosition()`. kS = 0.035 is ~17 µs of a +500 µs half-range, and CLAUDE.md's "0.035 = measured breakaway" lumps that electrical dead +zone together with mechanical friction into one number. Do not re-measure breakaway; +measure its **per-direction split** (see criterion 3 step 3) — an asymmetric dead zone +produces a *signed* azimuth residual. +``` + +### 3. CLAUDE.md contains three facts that are wrong or unusable + +**Problem:** an agent that trusts these will grep the wrong tree, chase a read cache that does not exist, and benchmark against a loop rate nobody can reproduce. + +**(a)** Replace the `cache = 0.01` bullet, CLAUDE.md:158–159: + +``` +- **`cache = 0.01` is a servo *write* deadband — not a read cache, not a time.** In + `CoaxialPod` it is `servoCachingThreshold` / `motorCachingThreshold`: `move()` calls + `turnServo.setPower()` only when `|turnPower − lastTurnPower| > 0.01` (plus a forced + write when turnPower hits 0). Dimensionless power units. It is **not** a pod + encoder-read interval and **not** LynxModule bulk caching. Confirm against the vendored + `third_party/PedroPathing` source; Task 0 item 2's premise is wrong and must be + corrected in the same commit. +``` + +And fix SWERVE_TASK.md:111–113 to stop asserting the read-cache reading. + +**(b)** Add to CLAUDE.md §3, right after the code tree (line 75): + +``` +**`CoaxialPod` is not in TeamCode.** It is `com.pedropathing.ftc.drivetrains.CoaxialPod`, +vendored at `third_party/PedroPathing/ftc/src/main/java/com/pedropathing/ftc/drivetrains/` +(Pedro gained native swerve in 2.1.0; `FollowerBuilder.swerveDrivetrain(SwerveConstants, +pods...)` wires it). Greps scoped to `TeamCode/` will miss the pod control loop, the +encoder→angle map, and the shortest-path flip. Read the vendored source, not upstream +docs — `turnKS`, `turnKSBandDeg`, `cache` and `PositionalPod.java` are not stock 2.1.2, so +this tree is forked or wrapped; say which. + +**Third bucket for the shipped/diagnostic rule: `vendored` — `third_party/PedroPathing/**`.** +Editing it forks an upstream dependency and `includeBuild` rebuilds it into every module +with no version bump. Flag any such change and name the upstream 2.1.2 behaviour it +changes. +``` + +Mirror the `vendored` bucket into SWERVE_TASK.md Standing rules (line 82–84). + +**(c)** Amend CLAUDE.md §6 (lines 165–166): + +``` +| Loop, DRIVE, after batteryVolts fix | **30.9 Hz true — DISPUTED, see note** (was 19.4–27.4) | +| Loop, DRIVE, bimodal | 29% @ 8.9 ms (publish skipped) / 71% @ 53.6 ms (publish runs) | + +> **These two rows do not reconcile.** 0.29·8.9 + 0.71·53.6 = 40.6 ms = **24.6 Hz true**. +> 30.9 Hz would need a 52.5% slow fraction, not 71%. mean(1/dt) on the 29/71 split = +> 45.8 Hz, and 45.8/24.6 = 1.86× — inside the documented inflation band — so the split is +> self-consistent and 30.9 is the odd number out. Treat 30.9 Hz as unverified, and with it +> the "19.4–27.4 → 30.9" delta in §7. +``` + +And insert as Task 0 item 0 (before SWERVE_TASK.md:105): + +``` +0. **Reconcile the baseline before improving it.** From ONE SwerveBringUp DRIVE trace, + report together: `1/mean(dt)`, the dt histogram with both mode locations and their + sample fractions, and `mean(loopHz)` labelled inflated. The fraction-weighted mean dt + must match `1/mean(dt)` within 5%. CLAUDE.md's triple fails this — it implies 24.6 Hz. + Amend CLAUDE.md §6 and §7 in the same commit, and do not claim any DRIVE loop + improvement against 30.9 Hz. +``` + +### 4. Task 2's hypothesis list misses two Pedro-side causes and will actively steer the agent away from the right family + +**Problem:** hypothesis 0 ends with "if no edge flashed, cross this off and move to 1" — but `CustomDrivetrain.runDrive()` applies the identical per-axis clamp one layer down, unconditionally, on every deceleration. And X-lock is a stock Pedro default with no hysteresis that also fires mid-path in autonomous, not a hand-written `SwerveBringUp` quirk. + +Insert as hypothesis 0b, after SWERVE_TASK.md:198: + +``` +0b. **The same defect one layer down, in Pedro itself.** `CustomDrivetrain.runDrive()` + (`third_party/PedroPathing/core/src/main/java/com/pedropathing/drivetrain/CustomDrivetrain.java`) + calls `clampReversePower()` **separately on the robot-frame X and Y components**, + capping each to ±0.2 whenever that component opposes the measured velocity on the same + axis, then passes the pair to `arcadeDrive(forward, strafe, rot)`. Clamping one axis + and not the other **rotates the commanded translation vector**; the swerve + `arcadeDrive` then recomputes `atan2(forward, strafe)` and pushes the rotated angle to + all four pods. No enable flag, fires on every deceleration and every reversal. + **Crossing off hypothesis 0 because no box edge flashed does not cross this off.** + - **Applicability, one grep, do it first:** does the competition path reach + `runDrive()` in teleop (`setTeleOpDrive` / `setTeleOpMovementVectors` → + `follower.update()`), or go straight to `CoaxialPod`? If straight to `CoaxialPod`, + this cannot cause the stick symptom — record it as a Task 3 finding and move on. + - Falsifying column: log `translationalVector` X/Y **before and after** the clamp, + `robotVelocity` X/Y, and the resulting `atan2`. Smooth pre-clamp angle with a stepped + post-clamp angle confirms it. + - Fix: clamp the scalar projection of the command onto the velocity direction and + rebuild the vector at the original angle. **Vendored, shipped** change — label it a + fork of upstream 2.1.2 and report the diff. + - It is live during the Task 3 path run either way, so it can contaminate verification + graphs 1 and 4. +``` + +Replace hypothesis 1 (SWERVE_TASK.md:200–204): + +``` +1. **X-lock — it is a stock Pedro default, not team code, and it fires in autonomous + too.** `com.pedropathing.ftc.drivetrains.SwerveConstants` defines + `enum ZeroPowerBehavior { X_LOCK, IGNORE_ANGLE_CHANGES }` (only those two — there is no + "hold last angle") and `defaults()` sets `zeroPowerBehavior = X_LOCK`, `epsilon = 0.05`. + `Swerve.arcadeDrive()` computes `zeroTrans = |rawTrans| < epsilon` and + `zeroRotation = |rotation| < epsilon`; when both hold under `X_LOCK` it overwrites every + pod vector with the pure rotation vector — the ±45° X pattern, i.e. exactly the symptom. + **There is no hysteresis on `epsilon` anywhere in the library**, so a stick resting at + the edge toggles all four pods between X and the stick target every loop. + - First action is a read: report this robot's configured `zeroPowerBehavior` and + `epsilon`. `SwerveBringUp.java:2532` may be a *separate* hand-written park — check + both, and say whether the teleop path actually reaches `Swerve.arcadeDrive()` or goes + straight to `CoaxialPod`. + - Falsifier on `tgt`: under X-lock chatter `tgt` alternates between the stick angle and + a fixed X angle on consecutive loops while stick magnitude sits within ~0.01 of + `epsilon`. Log stick magnitude alongside `tgt`. + - **This also runs in autonomous**: `Follower.update()` → `CustomDrivetrain.runDrive()` + → `Swerve.arcadeDrive()`, so any low-power stretch of a Task 3 path (end-of-path creep + especially) can snap all four pods mid-path. Expect it in verification graph 1; do not + blame follower gains. + - Fixes: (i) `zeroPowerBehavior(IGNORE_ANGLE_CHANGES)` — note it suppresses turn power + rather than holding an angle; (ii) add magnitude hysteresis on `epsilon` (enter below, + leave above ~1.5×). Both are **vendored** changes — report the diff. +``` + +### 5. Task 3 is unbuildable as written: Pedro 2.1.2 has no profiler, the API the agent will pattern-match was deleted, and the continuity spec manufactures the defect criterion 1 forbids + +**Problem:** four separate traps. There is no jerk limit, acceleration limit, or motion profile anywhere in Pedro 2.1.2, and `PathConstraints.setVelocityConstraint()` — which reads exactly like a speed cap — is an *end-of-path completion tolerance* that will silently break the path's end condition while leaving speed uncapped. The `Point` class every web example uses was deleted in 2.0.0. `HeadingInterpolator`'s own javadoc example passes degrees to a radians API. `PathChain` does not check C0, let alone C1. And on a swerve, module azimuth rate is `θ̇ = κ·v`, so a C1-only joint is a step in commanded azimuth rate — a jerk limit bounds none of it. + +Replace SWERVE_TASK.md:254–269 (the first four Task 3 bullets): + +``` +- **Pedro 2.1.2 API — the `Point` class was DELETED in 2.0.0.** Every Pedro example on the + web is 1.x (`new BezierCurve(new Point(x, y, Point.CARTESIAN), ...)`) and **will not + compile**. Do not search for examples. Pedro is vendored at `third_party/PedroPathing` + via `includeBuild` — read that tree, do not hand-roll a spline library, and use these + v2.1.2 signatures: + - `com.pedropathing.geometry.Pose(double x, double y, double heading)` — inches, + **heading in radians**. + - `com.pedropathing.geometry.BezierCurve(List)` — any degree; a cubic is 4 poses. + Also `BezierLine(Pose, Pose)` and `BezierPoint(Pose)` for a hold point. + - `com.pedropathing.paths.Path(Curve)`; `follower.pathBuilder().addPath(...).build()` → + `com.pedropathing.paths.PathChain`, or `new PathChain(Path...)` directly. + - `follower.followPath(chain[, maxPower][, holdEnd])`; `follower.holdPoint(Pose)`. + - For the continuity checks below: `Curve.getPose(t)`, `getDerivative(t)`, + `getSecondDerivative(t)`, `getEndTangent()`, `getCurvature(t)`, `length()`. + If a symbol above does not resolve, the vendored tree diverges from upstream 2.1.2 — + diff it and report that; do not go debugging the build. + +- **C2 (curvature-continuous) at every joint — not C1.** Module azimuth rate is θ̇ = κ·v, + so a curvature step is a step in commanded azimuth *rate*. Build by the reflection rule, + but **the construction rule is not the evidence.** `PathChain(Path...)` only appends paths + and sums lengths — it checks nothing, not even C0, so a chain with a 6 in gap builds and + runs and the follower just yanks toward the new curve. Before the path runs, print a + per-joint table: **C0** `|end(i) − start(i+1)|` in inches (above 0.01 in is a + transcription bug); **tangent direction**, the angle between `getEndTangent()` of segment + i and `getDerivative(0)` of segment i+1, in degrees — each `Path` carries its own t and t + is not arc length, so compare directions, not magnitudes; **curvature**, `getCurvature(1)` + vs `getCurvature(0)`, reported as a step in 1/in. State |Δκ| at every joint; it must be 0. + Where geometry forces a corner, dwell (v → 0) through it. Report the measured number per + joint — "C1 achieved" without the table does not count. + +- **Speed/accel: Pedro 2.1.2 has no motion profile — no jerk limit, no acceleration limit, + no time parameterization anywhere in `core/` or `ftc/`. Do not go looking for one.** It is + a reactive vector follower. **`PathConstraints` is not a motion-constraint class.** + `velocityConstraint` (default 0.1 in/s), `translationalConstraint`, `headingConstraint`, + `tValueConstraint`, `timeoutConstraint` are all *end-of-path completion tolerances* — + `setVelocityConstraint(30)` does **not** cap speed at 30 in/s, it ends the path as soon as + speed drops below 30 in/s. The knobs that actually exist, and all you may use: + `follower.setMaxPower(p)` / `followPath(chain, maxPower, holdEnd)`; `brakingStrength` / + `brakingStart` / `usePredictiveBraking` (end-of-path deceleration only); + **`FollowerConstants.centripetalScaling` (default 0.0005) — this plus curve geometry is + your actual smoothness control**. `forwardZeroPowerAcceleration` / + `lateralZeroPowerAcceleration` are *measured coast decelerations* feeding the braking + model, not limits you set — do not present them as an acceleration limit. + So bound acceleration **geometrically, not temporally**, and report peak lateral + acceleration as `v²·κ_max` with `v` from `maxPower`, labelled **estimated**. Report a jerk + number only from logged velocity differentiated twice with a stated filter — at 30–50 Hz + with the known bimodal dt, an unfiltered double difference is noise. Otherwise write + **"jerk not measured"**. Do not report a limit you never enforced. + +- **Azimuth rate caps speed before accel or jerk does.** Solve v(s) subject to + κ(s)·v(s) ≤ 171 °/s = 2.99 rad/s first (0.8 × the 214 °/s median slew; also below the + 184 °/s slowest measured pod), then accel, then bounded jerk. At R = 10 in (κ = 3.94 /m) + that caps v at 0.76 m/s = 30 in/s. Uncapped, a ±10 in S-curve at 0.5 m/s demands a + 226 °/s azimuth-rate step — above the pod's slew ceiling, so the pods fall behind and + cross-track error grows through the joint. State the binding constraint per arc-length + station. + +- **State how heading is interpolated per segment and name the method. Pedro takes RADIANS + everywhere — write `Math.toRadians(...)`, never a bare degree literal.** Per-path on + `PathBuilder`: `setTangentHeadingInterpolation()`, `setConstantHeadingInterpolation(h)`, + `setLinearHeadingInterpolation(start, end[, endT[, startT]])`, `setReversed()`, + `setHeadingInterpolation(HeadingInterpolator)`. Whole-chain: the same names with a + `Global` prefix. In `com.pedropathing.paths.HeadingInterpolator`: `tangent`, + `tangent.offset(rad)`, `constant(rad)`, `linear(startRad, endRad[, endT])`, and + **`facingPoint(x, y)` / `facingPoint(Pose)`** — hold a target in view; usually better than + `constant` on a scoring approach because it self-corrects. Add `facingPoint` as option (d) + in the Survey 0 heading question. **Two traps, both in the vendored source:** (1) the class + javadoc says "these methods all use radians" and its own example block directly below + writes `constant(45)` labelled as degrees — that is 45 radians; do not copy a literal out + of Pedro's docs. (2) `PathChain.getHeadingGoal` feeds a `setGlobal*` interpolator + `chainT` = arc length travelled ÷ total chain length, while a per-path interpolator gets + that path's raw **Bezier t**. These diverge on a curve — pick one family per chain and say + which. Heading interpolation is a feasibility constraint, not a style choice: chassis + rotation adds ω to θ̇, tangential gives ω = κ·v (continuous if the path is C2), constant or + linear gives piecewise-constant ω that steps at every joint. If not tangential, smooth ω + across the joint and report the resulting azimuth-rate step. +``` + +Replace the visualizer bullet (SWERVE_TASK.md:298–299): + +``` +- **The visualizer is a sanity picture, not the clearance check.** It draws an absolute + field frame; the box lives in the pose frame with its origin wherever pose was last reset, + so the visualizer cannot show box clearance at all. Before opening it, write an offline + check that samples every `Path` in the `PathChain` at ≥200 points per segment and prints: + min clearance to the envelope from consequence 1, in inches, with the `t` where it occurs; + pass/fail against consequence 4's robot half-width; max |curvature| and the `t` where it + occurs; and per-module **commanded azimuth rate** vs arc length with the 214 °/s slew + ceiling and 171 °/s design limit drawn — any pod crossing 171 °/s means reshape the path, + do not run it. Commit it as a test so it reruns on every control-point edit. **Then** open + the visualizer to eyeball gross shape. Label the handoff "validated numerically against + the envelope; untested on the robot" — that supersedes "validated in the visualizer" + above. +``` + +### 6. CLAUDE.md tells the agent to stop; SWERVE_TASK.md tells it not to. CLAUDE.md is re-read on every compaction, so CLAUDE.md wins + +**Problem:** §8 rules 5, 7, 9, 10 are halt instructions. Rule 9 in particular forbids the whole of Task 0 (instrument `publish()`, add timing to `DriveTeleOp`) and Task 1's recorder extension. + +Replace CLAUDE.md §8 items 5, 7, 9, 10: + +``` +5. **You cannot touch the robot.** Deploying restarts the app, killing the OpMode and the + HTTP server. Batch it: queue changes, post one OPS REQUEST (build green → operator + installs → starts `Swerve Bring-Up` → replies "ready" + volts), and work an offline + thread while you wait. Never one deploy per change; never idle waiting. + +7. **Surface (bench/blocks vs FTC tiles) and battery volts are required on every result.** + Take them from the OPS REQUEST reply. If you do not have them, record `surface=UNKNOWN` / + `volts=UNKNOWN` and ask in the next batched survey — do not stop. + +9. **Declare diagnostic-tooling changes that alter the measurement; do not ask.** Before + taking data with it, log the change and its expected effect in `FINDINGS.md`, and keep + the pre-change numbers there for comparison. Ask only if it would make an + already-collected dataset unreadable. + +10. **Present options as a survey item with a recommendation and an explicit default, then + work against the default.** Never stop the run for the operator to choose an order. +``` + +Add to the §8 preamble: + +``` +> **Precedence:** during an autonomous run, the task prompt's run-control rules (batching, +> defaults, do-not-stop) override anything below that reads as "ask and wait". The evidence +> and safety rules (1–4, 6, 8, 11) are never overridden. +``` + +### 7. "BLOCKING" is undefined, so the agent can end the run by labelling its own question + +Replace the second bullet under "Rules:" (SWERVE_TASK.md:42–45): + +``` +- Mark each item **BLOCKING** or **NON-BLOCKING**. An item is BLOCKING **only** if taking + the default could damage hardware, drive the robot into a wall or a person, or destroy + measured data you cannot recapture. Nothing else. Thresholds, statistical choices, which + experiment to run first, shipped-vs-diagnostic scope, naming, and file layout are **always + NON-BLOCKING** — take the default, log it in `ASSUMPTIONS.md`, keep working. If you cannot + write a default for an item, the item is malformed: split it until you can. When a late + answer contradicts a default you took, apply it going forward and re-run only the work + whose result it changes. +``` + +### 8. "Batch your deploys" and "one change, one test" are irreconcilable — the fix is never stated + +Replace the paragraph beginning "So do not ask for a deploy per change" (SWERVE_TASK.md:58): + +``` +**Deploys are the scarcest resource — make each one carry many experiments.** Ship every +experimental change behind a **runtime toggle** drained through the existing queued-command +path (`setPidf`, `setPublishHz`, `pidStep` prove it works), so one install yields A/B/C arms +with no reflash. Note `config.jsonc` is compiled in and read at OpMode init — editing it +still costs a deploy. A knob that can only be exercised by reflashing is a last resort; say +why. + +**"One change, one test" binds at trial granularity, not deploy granularity.** One deploy +may carry ten toggles; each trial varies exactly one, and each trial record states the full +toggle vector. If a build unlocks only one experiment, you have not batched — find the +others first. + +So do not ask for a deploy per change. Batch them, then post one **OPS REQUEST** block: +``` + +### 9. Task 0 reads as a gate on the repo's hardest unsolved problem + +Replace Task 0's "Target before moving on" block (SWERVE_TASK.md:131–134): + +``` +**Target (not a gate):** `DriveTeleOp` at ≥50 Hz true, p90 dt < 25 ms. The servo PWM frame +is 20 ms ≈ 50 Hz, so past that nothing improves at the steering hardware. If it already +meets this, say so and move on — do not optimize the diagnostic tool for its own sake. **If +it misses, record `loop_hz_true`, mean/p90/min/max dt, and your best hypothesis, then go to +Task 1 anyway.** Loop rate is already refuted as the cause of the azimuth residual +(CLAUDE.md §7), so Tasks 1–3 are valid at 40 Hz with the rate recorded as a covariate. + +**Budget:** the 37.4 ms `publish()` residual is a known-open problem — **one instrumented +attempt**. If you have not localized it, report the breakdown you did get, mark it `open`, +and move on. Task 0 gets at most **2 deploy cycles**. + +**The loop-rate refutation does not cover this regime — read before skipping.** It ran 47.8 +vs 92.1 Hz: both arms at or above the 20 ms / 50 Hz servo frame, so both got a fresh command +every frame. DRIVE runs below that, where a large fraction of frames get no new command and +a 53.6 ms dt holds one across 2.7 frames. So the ≥50 Hz target is a mechanism, not hygiene, +and the A/B compared *mean* rate, never jitter — **p90 dt < 25 ms is the real gate and the +mean is secondary.** Deliverable: state `DriveTeleOp` vs `SwerveBringUp` true rate against +the 50 Hz line. If bring-up is below it and `DriveTeleOp` above, every azimuth trace in the +archive was taken in a regime the shipped code never enters — say so. **Only in that case**, +a randomized interleaved **25 vs 50 Hz** A/B (n ≥ 20 pod-runs/arm, tiles, volts logged) is +**permitted and is not a re-run** — it tests the sub-frame regime CLAUDE.md lists as open +("a knee in loop rate between 20–48 Hz in DRIVE") and overrides "do not re-run those" for +loop rate only. If both OpModes already ship above 50 Hz, loop rate is closed: stop +instrumenting it. +``` + +Add to Standing rules: + +``` +- **Breadth before depth.** Attempt every criterion at least once before iterating a second + time on any one, and cap first-pass on-robot iterations at **3 per criterion**. When you + hit the cap, write the number you hit, the cause, and the next experiment, and move on. + Note iteration counts in `FINDINGS.md`. +``` + +### 10. Every per-sample statistic has the `mean(loopHz)` defect, and "steady state" is never defined + +**Problem:** the recorder samples once per loop and the loop is bimodal, so fast loops are 29% of samples but 6.4% of elapsed time — no unweighted per-sample statistic is a time-average. Separately, criterion 3's statistic, window, and aggregation are undefined, so the same trace supports any answer between 0.1° and 5°. + +Add to Standing rules, right after the loop-rate line (SWERVE_TASK.md:80): + +``` +- **The recorder writes one sample per loop, and the loop is bimodal — samples are not + evenly spaced in time, so no unweighted per-sample statistic is a time-average.** At the + measured 29%/71% split of 8.9/53.6 ms, fast loops are 29% of samples but 6.4% of elapsed + time: a 4.6× over-weight. `mean(loopHz)` is one case of this, not the whole of it. + Time-averages: `sum(x*dt)/sum(dt)`, not `mean(x)`. Quantiles: sort by x, accumulate dt, + take the value at the target fraction of total dt — not `numpy.percentile` on the raw + sample array. Applies to mean and p95 |azimuth err| and heading error (criteria 3, 7, 8) + and every histogram in Task 2 and Verification. It does **not** apply to criterion 5 (a + ratio of integrals) or criteria 2 and 6 — but those must be pooled + `total_count / total_elapsed_s`, never a mean of per-chunk rates. +- **Loop samples within a run are autocorrelated — 3000 samples are not n = 3000.** For any + interval or test over a within-run series, report lag-1 `rho` and + `n_eff = n(1−rho)/(1+rho)`, and get the interval from a moving-block bootstrap with blocks + ≥ 1.1 s (3× the 0.37 s rise), never the iid formula. At rho = 0.9 a 3000-sample chunk + carries n_eff ≈ 158 and the naive SE is 4.4× too small. No p-value on a within-run series + unless it came from that bootstrap. For the 45° clustering check, report the **resultant + length of 8× azimuth** (0 = uniform, 1 = fully locked to 45° multiples) with a + block-bootstrap interval, not a uniformity p-value. +- **A robot-level treatment is replicated at the RUN, not the pod.** Loop rate, gains, build + and publish path apply to all four pods at once, and pods within a run share battery, Lynx + bus, timing and surface. Average the four pods to one value per run and do the A/B on + n = runs. Write "n = 10 runs (40 pod-observations)", never "n = 40 pod-runs". Report per + pod as well as pooled — a pooled mean hides one bad pod inside three good ones, which is + the likeliest shape of the criterion-3 residual. The loop-rate refutation was analysed at + n = 40 pod-runs, so its CI [−0.51, +0.71] is up to 2× too narrow; run-level analysis does + not move the point estimate (+0.12°), so it **stays refuted, do not re-run it** — correct + the CI in CLAUDE.md §7 if you can recompute from `trials.jsonl`. +``` + +Insert directly above the criteria table (SWERVE_TASK.md:330): + +``` +**Fix these measurement definitions before taking data. Do not change them after seeing a +trace.** + +- **t0** = the loop where the commanded azimuth setpoint steps by > 45°. Hold each step + ≥ 2.5 s. +- **Criterion 4 settle time** = first time after t0 that |err| stays inside ±2.0° + continuously for 250 ms. +- **Criterion 3 window** = [t0 + 1.5 s, t0 + 2.5 s]. That is > 2× the measured 0.647 s + settle, so it is past the transient whether or not the step settled. +- **Criterion 3 statistic** = **dt-weighted median |err|** over that window. Report the + dt-weighted **signed mean** (bias) and **peak-to-peak** (limit-cycle amplitude) alongside + it, always: 0.4° bias with 5° p-p and 2.8° bias with 0.2° p-p are different failures and + must not collapse to one number. +- **Aggregation**: per pod, ≥ 10 steps, median and IQR across steps. Do not pool the four + pods into one number. +- The **2.65–3.01° baseline was computed by an unknown statistic**. Re-derive it under this + definition before claiming any improvement against it. +- Criterion 4's ±2° band is deliberately looser than criterion 3's 1.0°: 4 measures + transient speed, 3 measures the floor it settles onto. Both must hold. +``` + +Amend criterion 1 and criterion 3 rows: + +``` +| 1 | Unintended azimuth setpoint **rate** `\|Δtgt\|/dt` between consecutive loops | **≤ 170 °/s** (0.8 × measured 214 °/s pod slew) — report max **and p99** — and zero clustering at 45° multiples. Deliberate shortest-path flips excluded but **counted and reported** | 45° snapping observed | +| 3 | Per-module azimuth steady-state error — dt-weighted median \|err\| over [t0+1.5 s, t0+2.5 s], median across ≥10 steps, per pod | **< 1.0°**, with signed bias and p-p reported alongside | 2.65–3.01° (statistic **unknown** — re-derive) | +``` + +with this note under the table: + +``` +Criterion 1 is a rate because Task 0 changes dt. The old "≤ 15° per loop" is 463 °/s at +30.9 Hz — 2.2× the pod slew, so a setpoint at the threshold is untrackable yet passes — and +750 °/s at 50 Hz, i.e. looser precisely when the loop gets better. 170 °/s is 5.5° per loop +at 30.9 Hz, 3.4° at 50 Hz. +``` + +### 11. Criterion 5 is a total-variation estimator, so Task 0's own loop-rate fix inflates it with zero change in robot behaviour + +Add as a note on criterion 5 and extend Verification item 6: + +``` +**Criterion 5 is Σ|Δθ| — total variation — so its noise term grows linearly with sample +count while its signal term does not. Task 0's publish() fix raises the DRIVE loop rate the +recorder samples at, which inflates criterion 5 with zero change in robot behaviour. Do not +read a post-Task-0 rise as a regression.** Compute it as: + +- Clip each sample delta at `259 °/s × dt_i` before summing. That is the measured pod slew + max; anything above it is provably noise, not motion. Physical bound, not tuned. +- Pool, never average ratios: `Σ wheel_TV / Σ commanded_TV` per pod across all chunks. + Exclude any interval below the magnitude gate (Task 2 hypothesis 5) — a stopped robot + contributes a zero denominator. +- Measure the floor in the same session: robot commanded to hold still, same surface, 60 s. + Report criterion 5 both raw and minus floor. A criterion-5 number without its same-session + floor is not admissible. +- Any criterion 5 or 6 comparison across a loop-rate change must be at matched sample rate — + decimate the faster trace to the slower one. Say that you did. +- Criterion 6 reads 0.00 reversals/s at rest against ~120° of encoder noise, so it already + deadbands and criterion 5 does not. State criterion 6's existing deadband numerically and + apply the same one to criterion 5. +``` + +And in CLAUDE.md §6, replace the at-rest row (line 176): + +``` +| At-rest baseline | ~120° — **summed |Δθ| path over a chunk, NOT an amplitude** | 0.00 reversals/s | +``` + +with this below the table: + +``` +**Metric definitions.** `wheel_path` = Σ|Δθ| over the chunk, in degrees. 120° as a +peak-to-peak amplitude is impossible (1.1 V of noise on a 3.3 V ratiometric 1:1 encoder) and +contradicts 0.00 reversals/s. It is either a path sum (≈1.2–4 °/s over a 30–97 s chunk) or a +0/360 wrap-seam artifact: a pod parked on the seam adds ~360° per sample. Report +`wheel_p2p_deg`, `wheel_rms_deg`, `wheel_path_deg_per_s`, chunk duration, and the fraction of +at-rest samples within 5° of the seam — **before** trusting criterion 5's ratio, which +carries this same numerator and is the likely source of its 1.7–3.0× spread. A `reversal` is +undefined without a hysteresis threshold; state it in degrees next to every reversals/s +figure or criterion 6 is arbitrary. **ADC quantization is ruled out by arithmetic — do not +experiment on it.** 12-bit over 3.3 V = 0.806 mV/LSB; at 360°/3.3 V = 109.1 °/V that is +0.088 °/LSB (≤0.14° through a 5 V divider) — 20–30× below the 2.7° residual. This supersedes +Task 2 hypothesis 4's "sensor noise or a conversion artifact" framing for the 120° figure; +the rest of that hypothesis still stands. +``` + +### 12. Ops: no abort word, no pre-flight, no speed cap, and "box armed" is not "box correct" + +Add as a new section before Task 0: + +``` +## ABORT + +"ABORT" from you means: I stop sending to `/command` and the bench client immediately, post +the last command I sent and when I sent it, and send nothing motion-capable until you reply +"re-arm". A survey answer, a "ready", or silence is not a re-arm. Any recorder chunk spanning +an abort is discarded, not analysed — say how many. + +Your fast stop is **STOP on the Driver Station**: it kills the OpMode, so I must then assume +the HTTP server is gone too. The dashboard's 400 ms watchdog only cuts the browser drive path +— backgrounding the tab is not an abort. + +I stop on my own and ask you to hit STOP if a pod's measured azimuth stops responding while +I am commanding it to move (a CRServo stalled against a hard stop), or if `/state` fails +twice in a row while a drive command is outstanding. + +## Pre-flight — first OPS REQUEST of the session, before any command that can move the robot + +Starred items repeat after every deploy or battery swap. One reply, not drip-fed. + +1. 3 ft clear around the 51 × 46 in box. Tape the box on the floor plus a second line 6 in + inside it. The tape is the barrier — you are about to test the software clamp for a + direction-rotating bug (Task 2, hyp 0), so do not rely on it to keep the robot in. +2. Battery strapped in, leads clear of the wheels, resting volts reported (≥ 12.5 V to start; + the baseline runs were 12.2–12.5 V). +3. *Turn each pod by hand through its full 270° travel. Report any bind, grind, play, or + debris in a wheel — a binding pod invalidates every kS/friction result before you measure + it. +4. *Encoder liveness: OpMode running, nothing commanded, operator turns each pod by hand + while you watch `/state`. All four azimuth angles must move. One that holds still is a + dead encoder: the CR loop sees a constant error and holds full command on that servo + indefinitely, and the 5 Hz four-servo rail total will not show it. STOP — do not command + steering. +5. Sticks read ~0 at rest. Drift here fakes the deadband-0.06 chatter in Task 2 hyp 1. +6. Driver Station within reach, STOP visible. + +## "Armed" is not "correct" — the box can drift + +The box is stored in the Pinpoint's dead-reckoned pose frame, so odometry drift slides and +rotates it relative to the floor while `/state` still reports armed. 3° of yaw error moves the +far corner of a 51 × 46 in box (68.7 in diagonal) by 3.6 in. Drift over a run is **unmeasured** +— Task 1 measures it. So "confirm the box is armed" is necessary and not sufficient. In the +same OPS REQUEST that re-marks corners A and B, add: put the robot back on the corner-A +position, square to the box — I read x, y from `/state`; repeat at corner B. PASS if both land +within **2 in** *(guess — Survey 0)* of the marked coordinates and the implied box is +51 × 46 ± 2 in. On fail: reset pose, re-mark, redo — I do not command motion on a box that +failed. Log each pair in `FINDINGS.md` as the drift measurement Task 1 wants. + +**Re-mark procedure (this is the order, and the order matters):** (1) place the robot on the +corner-A mark, square to the box, +x along the 51 in side, using the FIELD panel's footprint +indicator centre as the anchor point at both corners; (2) press **Reset pose** — this must come +BEFORE marking, because it clears the box, so pressing it after Mark B destroys the box you +just made; (3) **Mark corner A**; (4) drive to the diagonally opposite mark (+51 in x, +46 in y) +holding the same heading, press **Mark corner B**; (5) reply with the x, y the FIELD panel reads +at corner B. + +## Speed cap + +Baseline is p95 driving azimuth error ~42° with 97 flips in one chunk — pods fighting at speed +scrub and lurch, and a 180° flip reverses a loaded wheel. + +- Cap every diagnostic drive in Tasks 0–2 at 30% of full commanded translation and rotation + until criteria 1, 2, 5 and 6 hold. Convert it to in/s off the FIELD panel `v` readout, record + it with every result, Survey 0 default 30%. +- Task 2 hypothesis 0's edge-proximity check is a deliberate drive into the hard limit, and the + hypothesis itself predicts it will chatter. Run it at 15%, one axis at a time, maximum 3 clamp + engagements per run, then stop and disarm. Say in the OPS REQUEST that this run drives into + the clamp on purpose. +- The clamp acts on commanded velocity, not position, so it does not kill momentum — the robot + coasts past the edge. Measure that overshoot once from the pose trail at the capped speed. If + it exceeds the Task 3 item 4 half-width margin, lower the cap until it does not. + +## On blocks is not the safe state + +Rule 8 covers driving off the floor; it does not cover on-blocks work, which is where the pod +step tests and the kS comparison happen. Every OPS REQUEST that puts the robot on blocks gets +these as numbered steps: clamp or strap the chassis to the blocks (`pidStepAll` slews four pods +at once at 214 °/s; the reaction can walk an unsecured chassis off them); hands, cables and the +gamepad lead clear of all four wheels before you reply "ready". +``` + +### 13. No resumption state — a compaction or a battery swap loses the run + +Add after "Robot operations — also batched": + +``` +### `RUN_STATE.md` — so an interruption does not cost robot time + +Keep `RUN_STATE.md` at repo root. Rewrite it in full after each task's result block, before +each OPS REQUEST, and after each operator reply. A compaction or a new session loses +everything else. + +``` +updated: +phase: Task 0|1|2|3 | Verification | wrapping up +last_commit: (build green? y/n) +on_robot_build: # what the operator last installed +opmode_running: SwerveBringUp | DriveTeleOp | none | UNKNOWN +surface: tiles | blocks | UNKNOWN +battery_v: @