Skip to content

Swerve pod rotation tuning - #1

Merged
AXAStudio merged 72 commits into
masterfrom
swerve-pod-rotation-tuning
Aug 19, 2026
Merged

Swerve pod rotation tuning#1
AXAStudio merged 72 commits into
masterfrom
swerve-pod-rotation-tuning

Conversation

@AXAStudio

Copy link
Copy Markdown
Owner

No description provided.

The bring-up tool builds its own Swerve rather than reading
SwerveDrivetrainConstants, and had IGNORE_ANGLE_CHANGES hardcoded, so
changing the competition constants had no effect on the drive test.

It now defaults to X_LOCK to match competition, with a dashboard toggle
back to hold-heading for diagnosis - X_LOCK yanks all four pods to the X
on any momentary input loss, which makes pod behaviour unreadable while
tuning. The visualizer now draws the radial targets X_LOCK commands
instead of showing nothing.
Snapshot of the working tree as found, so the pod-rotation tuning session starts from a clean tree with a known baseline. No behaviour changed here.

- SwerveBringUp/dashboard: robot-heading step + goto routines, X_LOCK toggle
- SwerveDrivetrainConstants: heading PIDF 1.75/0/0.003 -> 1.20/0/0.030, measured 2026-08-11, with the findings comment block
…ate fix, measurement harness

All numbers below measured on blocks, 12.9-13.3 V, pidStepAll 90 deg, n>=12 pod-runs
per configuration. Traces and per-trial metrics are in tools/swervetune/runs and
tools/swervetune/trials.jsonl.

Plant characterisation (new):
- encoder noise floor sigma 0.042-0.054 deg, below the 0.1125 deg read granularity
- open-loop breakaway 0.025-0.050, consistent across pods, ~1.6x harder one direction
- max slew 726-793 deg/s; actuation latency 36-97 ms
- no mechanical outlier pod

Root causes found:
- kF is a sign-only relay, and at the shipped 0.005 it is 5-10x below breakaway, so it
  does nothing. The pod parks where kP*e drops under breakaway: 0.035/0.300 = 6.7 deg.
  Measured residual 3-6 deg, matching.
- servoCachingThreshold 0.05 is wider than the entire breakaway range, freezing the
  written power across a ~19 deg error window near the target.
- loop rate was 33 Hz under a 4-pod hold, slower than the 20 ms servo PWM frame. The
  control work was only 1.2-3.4 ms of it; the rest was state serialisation, telemetry,
  a Pinpoint I2C read pod rotation never uses, and 4 analog reads only a wire scan needs.

Changes:
- CoaxialPod: setStaticFriction(power, band) applying power*tanh(error/band); getters for
  last written turn power, last post-flip error and last flip state. No-op at zero.
- PIDFController: setIntegralResetThreshold and setIntegralBand, both no-ops at default.
  The existing sign-change reset was firing on encoder noise at the target, which is
  precisely where the integral was supposed to help.
- SwerveBringUp: loop-rate recorder for all four pods, /swerve/rec.csv, rawServo command,
  per-stage loop timing, throttled publish, on-demand Pinpoint and channel reads.
  setPidf scope=all now propagates cache, ks and the integral settings.
- tools/swervetune: HTTP client, step scorer, plant characterisation, FTC Dashboard
  WebSocket client so OpMode restarts do not need a Driver Station.
- README: Axon Mini MK2 -> MK1 (confirmed against the physical servo).

Measured off-ground result so far, kP 0.20 kD 0.020 kS 0.035 band 2 kF 0 cache 0.01:
|steady-state| 3.52 -> 0.51 deg mean, overshoot 1.1% mean, rings 0.69 mean,
post-settle peak-to-peak 0.34 deg, resting power RMS 0.0115 (below breakaway),
loop 33 -> 96 Hz. Not yet meeting settle-time, worst-case ring or pod-spread criteria;
SwerveDrivetrainConstants is deliberately unchanged pending carpet validation.
DriveTeleOp ran its loop at 30.7 Hz (32.5 ms), longer than the steering servos' own
20 ms PWM frame, so the pod turn PID was updating less often than the hardware could
accept commands. Loop rate turned out to be the single biggest lever on pod ringing
(33 -> 100 Hz on the bench cut error sign changes from 1-4 to 0-1 with gains untouched),
so this is a pod-rotation fix, not a cosmetic one.

- Enable LynxModule bulk caching (AUTO). Nothing in the vendored Pedro tree touches
  LynxModule, so caching defaulted to off and every encoder read was its own transaction.
- Drop follower.getDrivetrain().debugString() from the loop. It calls getRawAngleRad()
  twice per pod on top of the two reads move() already does, builds a multi-line string,
  and MultipleTelemetry pushed it to the Driver Station and FTC Dashboard every loop.
- Throttle the remaining telemetry to 10 Hz and report loopHz so this stays measurable.

Measured before/after over 8 s on blocks, zero stick input, X_LOCK engaged:
  before  30.7 Hz mean (27.7-33.8), 32.5 ms
  after   75.8 Hz mean (65.5-85.5), 13.2 ms

Teleop is still slower than the bring-up bench (96-120 Hz) because it also runs
follower.update() and its Pinpoint read, so bench-tuned gains remain slightly
optimistic and need confirming in teleop.

Pod dead zone (phase2_backlash.py, operating gains, 8 approaches from each side):
  pod 0  0.60 +/- 0.56 deg      pod 2  0.01 +/- 0.44 deg
  pod 1  0.60 +/- 0.31 deg      pod 3  0.07 +/- 0.38 deg
No pod's directional gap exceeds twice its standard error, so there is no measurable
hysteresis. This bounds mechanical lash plus stiction at well under the 1.5 deg that
would make criteria 5 and 6 unreachable: the remaining shortfall is tuning, not hardware.
An earlier high-authority probe (kS 0.055, band 0.5 deg) was discarded - that setting
limit-cycles, and its scatter exceeded the effect it was measuring.
…ults, gate evaluation

Reconciled the 3.6 Hz limit cycle against the measured actuation path. Budget at
power 1.0, 6 repeats per pod per direction:
  transport      41 ms  (7 loop + 10 PWM frame average + 7 encoder sample + ~8 of
                         threshold-crossing artefact + ~9 bus and servo electronics)
  velocity lag   64 ms  (servo and gear train spinning up - this is the plant)
  total         106 ms  equivalent, against ~99 ms implied by the limit-cycle phase
                        budget once the kD lead is accounted for. Nothing is missing.

Negative results, both worth keeping:
- Widening the servo PWM pulse range 600-2400 -> 500-2500 us does nothing: v_inf 693
  -> 704 deg/s, inside the noise. The servo already reaches ~90% of top speed at 0.3
  power, about 270 us off centre, so the extra range is never used. Reverted.
  Halving the frame period to 100 Hz does work and saves 3.0 ms of transport delay,
  which is 3% of the budget; left at default, available if the margin is needed.
- Derivative-on-measurement does not unlock more damping. kD 0.040 rings 16x, 0.070
  and 0.110 are unusable. For a plant 1/(s(1+sT)) with T = 64 ms the PD zero cancels
  the lag pole at kD = kP*T = 0.0128, so kD 0.014-0.022 is already the optimum and
  more derivative only amplifies the residual transport delay. Kept as an option
  (default off) because it also removes the flip-boundary derivative spike.

Both are implemented and reachable, so the next person can re-test rather than re-derive.

The governing limit, measured directly: static-to-kinetic friction drop means there is
no creep regime. At or just above breakaway the pod already moves at 9-80 deg/s, so any
correction commits it to 1.0-8.5 deg of travel before feedback can act. That is the
floor under criteria 5 and 6 and the reason behaviour is bimodal - a pod that lands
inside the no-correction band parks to 0.34 deg peak-to-peak, and one that needs a
correction jumps several degrees and may hunt.

Pod 3 is a genuine outlier on this measure and my earlier all-clear was premature: it
sustains 38 deg/s at 0.025 power in one direction while needing over 0.060 in the other,
against 0.045-0.060 for its siblings. Worth a mechanical look.

Phase 4 gate is NOT met. Full section 7 table in the session report; every criterion
passes on the mean and fails on the worst case over 40 pod-runs.
SwerveDrivetrainConstants remains unchanged.
…validated)

Per-pod re-scoring of the gate traces refutes the one-bad-pod reading, and one of my own
claims with it. The worst pod is a different pod at each step size - pod 0 owns the worst
rings and peak-to-peak at 90 deg (23, 12.03), pod 2 at 45 deg (21, 12.15), pod 3 at 15 deg
(18) - and pod 3, which I had flagged as mechanically suspect, is the best pod at 90 deg
(rings 2, |ss| 0.51, 80% settled). Committed travel does not predict closed-loop
performance, so the hardware-wall argument was over-stated. No pod passes individually
either, so it is not one outlier masking three good pods.

Delay budget corrected. A 3-parameter least-squares fit to the travel curve, which is
clean, rather than to its derivative, which is not:
  V = 701 deg/s, td = 39 ms, tau = 42 ms, residual RMS 2.0 deg over a 181 deg move.
The 39 ms transport delay confirms the original 41 ms and is real actuation lag: both
timestamps come from the on-robot recorder, the servo write happens in the same loop
iteration as the first of them, and the frame-period experiment independently moves it.
But tau was inflated 42 -> 64 ms by timestamping a backward-difference velocity at the end
of its window instead of the middle, so total lag is ~81 ms, not 106 ms. That puts
achievable crossover near 12.8 rad/s and settling to +/-1 deg near 350 ms, which is at
criterion 1 rather than beyond it.

Pulsed final approach (CoaxialPod.setPulsedApproach, no-op when disabled). Outside the
band the PD loop is untouched; inside it, one bounded open-loop pulse fires, the pod
coasts, and it is measured again - so actuation lag never enters the stability question.
Where it works it is decisive: 11 of 16 pod-runs settled to |err| <= 0.34 deg with
peak-to-peak <= 0.34 deg and exactly zero holding power, meeting criteria 5, 6 and 8 with
margin. Two failure modes remain:
  1. stuck outside the band, no pulses, PD below breakaway. Widening the band 3 -> 8 deg
     lifted the settled fraction 69 -> 81%.
  2. pulse stacking. The hub's analog registers update more slowly than the control loop,
     so a stale reading reads as zero velocity and a second pulse fires while the pod is
     still coasting. A mandatory coast interval is implemented but NOT yet flashed.

Also measured: the servo PWM frame is 20000 us / 50.0 Hz, read back from hardware, and
halving it to 100 Hz works and saves 3.0 ms of transport delay. Left at the default -
see the session notes about the network dropout that coincided with testing it.

Experimental design: trials were run in blocks, not randomized, which was a mistake. The
pack drifted 13.29 -> 12.68 V across the session. The kP sweep (12.95-12.97 V) and the
kP 0.20 vs kP 0.10 frontier comparison (12.71-12.73 V) are each internally clean, but the
shipped-baseline vs candidate headline spans 0.57 V and must be re-measured on a fresh
pack before it is quoted anywhere.
…lse quantum

All trials this block randomised in order with per-trial voltage logged. Fresh pack,
13.58 -> 13.02 V, robot on blocks. Pack barely drained, so no criterion 12 coverage.

1. Pulsed approach, coast fix flashed. The mandatory coast made pulses regular but did
   not stop them: 21-22 pulses per 3 s is the maximum a 120 ms cycle allows, so the pod
   pulsed continuously and never entered tolerance. Calibration explains why. Travel per
   20 ms pulse - the shortest a 50 Hz frame permits - is 0.06-2.85 deg at 0.040 power,
   0.49-3.72 at 0.050 and 1.65-13.66 at 0.060. The minimum quantum already exceeds
   criterion 5's entire budget, and travel is not a repeatable function of duration:
   pod 0+ moves 2.85 deg at 20 ms but 1.80 deg at 45 ms, which is impossible for a
   deterministic system, so the scatter is intrinsic breakaway variability. Sizing a
   pulse from the error cannot work against 3x scatter at fixed command. Halving the PWM
   frame halves the quantum and is the specific thing that would unblock it.

2. Randomised kP/kD grid at the corrected tau. The hypothesis that every earlier gain was
   over-damped is refuted: more kD is better, not less. Configurations near the predicted
   optimum kD = kP*tau (ratio 0.34-0.68) are uniformly worse - kP 0.28 kD 0.004 rings 24.7
   with 56.9 deg peak-to-peak - while the best sit at ratio 1.7-2.4. Pole cancellation
   under-predicts because it ignores the 39 ms of pure transport delay, which needs extra
   lead. The earlier 0.014-0.022 finding stands and over-damping was not the cause of the
   1223 ms settle.

3. Bimodality confirmed. Pooling 968 pod-runs over 242 trials: tight mode 0.23 +/- 0.20 deg
   peak-to-peak (83%), loose mode 25.65 +/- 12.44 deg (17%), with an empty valley between.
   Loose rate per pod is 12.8 / 17.4 / 18.2 / 20.7 percent (+/-2.1-2.6), all one band, so it
   is not a pod property. Per configuration it ranges 0% to 69%, and six grid configurations
   drew zero loose runs out of 16 each. Criterion 10 was therefore measuring which pod drew
   the bad mode, not any property of the pod.

4. Best set so far, kP 0.20 kD 0.014 kS 0.035 band 2 kF 0 cache 0.01: post-settle
   peak-to-peak 0.21 mean / 0.45 max, which PASSES criterion 6 for the first time, rings
   0.85 mean, and pod spread down from 99-197% to 24-84%. Steady-state on a randomised
   interleaved A/B against the shipped gains on one pack: 3.23 -> 0.96 deg mean,
   7.24 -> 2.52 deg max. Settle time does not reproduce between blocks at identical gains
   (0.455 s vs 1.254 s), because criteria 1 and 5 are coupled through the metric: with the
   residual sitting near 1 deg, time-until-error-stays-under-1-deg is hypersensitive.

Gate still not met. PWM frame left at 20000 us throughout, as instructed.
…inal

Committed so the robot stops running the old constants, not because tuning is finished.
Off the ground only: not validated on carpet, at competition weight, or on a drained pack.

Randomised interleaved A/B against the shipped gains, same battery, 40 pod-runs each,
90 deg steps on blocks at 13.1 V:
  steady-state   3.23 -> 0.96 deg mean, 7.24 -> 2.52 deg max
  sign changes   2.35 -> 0.85 per step
  post-settle    0.53 -> 0.21 deg mean, 0.45 max (criterion 6 passes for the first time)

kF 0.005 -> 0 and replaced by kS 0.035 with a 2 deg band; servo caching 0.05 -> 0.01.
SwerveExport now emits setStaticFriction, setTurnIntegralSettings, setDerivativeOnMeasurement
and setPulsedApproach, each only when it is not at its default.

Criteria re-scored against the corrected definitions (criterion 1 = enter and stay within
+/-2.0 deg, criterion 5 = residual at t=3 s), which decouples them - the old pair both keyed
on 1.0 deg, so settle time was hypersensitive whenever the residual sat near 1 deg and the
same gains gave 0.455 s in one block and 1.254 s in the next. At +/-2.0 deg settle is
0.47-0.55 s with 88-100% of pods settling, against 1254 ms and 48% before.

Still not met off the ground: settle ~0.5 s against 350 ms, residual up to 2.5 deg worst
case, pod spread 24-84% against 15%.
Selection inverted as intended: filter to zero loose-mode runs first, then minimise residual,
rather than ranking on a blended score. The blended score had been picking configurations that
sometimes draw the 25 deg peak-to-peak mode.

Four candidates, 60 pod-runs each, randomised order, 12.95 -> 12.87 V:
  kD 0.022 b2.0   0/60 loose  (P <= 6%)   residual 0.77 mean / 2.36 max   settle 0.647 s, 97%
  kD 0.014 b1.2   1/60        (P <= 9%)   residual 0.80 / 2.84            settle 0.664 s, 95%
  kD 0.010 b2.0   1/60        (P <= 9%)   residual 0.97 / 2.47            settle 0.718 s, 90%
  kD 0.020 b2.0   3/60        (P <= 14%)  residual 0.86 / 3.40            settle 0.971 s, 98%

The earlier 0/16 counts were optimistic, as the rule of three predicted: three of the four drew
loose runs once n reached 60.

The residual wall is NOT a selection artefact. kD 0.022 holds zero loose runs and still has a
2.36 deg worst case, above the 1.5 deg bar. Per pod the residual is 0.76 / 0.73 / 1.06 / 0.52 deg
with sd ~0.55, so it is a broad distribution, not an outlier pod: 35% of pod-runs exceed 1.0 deg
and 12% exceed 1.5 deg.

Also documents the derivative-on-measurement and pulsed-approach patches in RUCKUS_PATCHES.md,
both negative results kept because they are cheap to re-test and expensive to re-derive.
…ition scatter

The mechanical pass needs a clean before/after, and the previous run could not give one:
each cell was a mean of 3 repeats, so the quoted sd was between pod/direction combinations
only and within-condition repeat scatter was invisible. They are different quantities and
the mechanical work targets both - between-condition is what a single pulse power has to
straddle, within-condition is what no calibration can remove.

Now reports both plus their combination against the 0.5 deg target, and archives every
pulse trace so the comparison can be re-scored later without re-running the robot.

Target derived from the current numbers, for the bench work to aim at:
  travel per 20 ms pulse   1.15 +/- 1.07 deg  ->  <= 0.35 +/- 0.15   (3.3x mean, 7x sd)
  v_break                  38 +/- 26 deg/s    ->  <= 17 +/- 8        (2.2x mean, 3.3x sd)
  breakaway, 8 pod/dirs    0.025-0.060        ->  max <= 0.035, range <= 1.4x
At a 10 ms quantum the whole requirement halves.
… external PID

New SwervePod implementation alongside CoaxialPod, not replacing it. FollowerBuilder takes
SwervePod varargs, so a drivetrain can mix them - which is what the one-pod A/B needs and what
the fallback needs if positional mode is worse. Lives in TeamCode rather than the vendored tree
because it implements a public Pedro interface and needs no Pedro change at all.

Rationale: every measured limit on the CR pod traces to one physical fact - the steering path
has no creep regime, so static friction breaks to kinetic and the pod goes from stopped to
9-80 deg/s with nothing between. Any external controller with the authority to correct a small
error commits to degrees of travel before 39 ms of transport delay plus a 42 ms velocity lag
lets it react. A servo closing its own loop internally makes breakaway its problem, not ours.

Design points worth keeping:
- 200 deg of programmed travel, not 355. 180 is all the flip needs, and narrower doubles the
  runtime resolution to 0.11 deg/step while shrinking the 1 us deadband in pod-angle terms.
- The 20 deg of overlap is deliberate: both representations of a heading are reachable there,
  so the changeover is a hysteresis band rather than a forced instant. That is the same
  discontinuity that chattered at 85-90 deg in CR mode.
- Seam placed at wheel heading ~20 deg, clear of all four measured occupancies (forward 90,
  strafe 0/180, X-lock 46.5 and 133.5). Chosen from drive-test headings, not match telemetry.
- The encoder's own wrap should sit in the same unreachable spot. It is non-monotonic there and
  produced a spurious 1452 deg/s slew reading; co-locating both dead zones costs nothing.
- initFromEncoder() adopts the pod's real position before the servo is enabled. A position-mode
  servo drives to its command the instant it has power, so without this the first move() of a
  match slams every pod at full torque. Soft Start limits how violently, it does not prevent it.
- getSlipDeg() is why the encoder stays wired. A slipped spline, stripped gear or overload
  cutout reports nothing at all; the only symptom is the pod not being where it was sent.
- Optional command slew limit gives setpoint profiling for free.

Calibration is two numbers: raw encoder angle at servo position 0 and 1.

Bench plan in tools/swervetune/POSITIONAL_AB_PLAN.md, covering programmer settings, the overload
protection test, why Sensitivity must be swept rather than maxed, and the fact that criterion 8
has no analogue in position mode and needs redefining before the A/B is scored.
Criterion 8 replacement measured, and the first instrument tried was wrong.

The Lynx servo rail channel exists in the protocol (SERVO_CURRENT, not surfaced on LynxModule but
reachable by sending the ADC command directly) and reads nothing useful on this robot: with all
four pods holding it moved -0 -> 10 mA against a 44 mA standard deviation. The reason is in the
config XML - the turn servos are fed from the servo power module, so the hub supplies only their
PWM signal. The battery channel does see the SPM: 189 -> 205 mA against a 12 mA sd, a 5.6 sigma
effect. Measurement moved there.

CR baseline, 12.74 V, 30 s per condition, n~128 per condition, other three pods PWM-disabled so
only the pod under test changes state:
  pod 0  -1 +/- 2 mA     pod 2  +4 +/- 2 mA  (elevated at 2 sigma)
  pod 1  +2 +/- 2 mA     pod 3  +3 +/- 2 mA
Pod 2 being the only elevated one lines up with it also having the worst residual (1.06 deg) and
the worst post-settle peak-to-peak (1.24 deg): it is working hardest at rest.

The script's own resolution guard fired and was wrong, so it has been corrected. It compared idle
against PWM-disabled, but for a CR servo those are genuinely almost identical - an undriven CR
servo is not turning its motor either way - so a null there says nothing about the instrument.
Resolution is properly demonstrated by the per-pod deltas summing to the all-four measurement.

A/B design fixed to within-pod. Scoring a reflashed pod against fleet aggregates would have been
invalid: the per-pod residual spread (0.76/0.73/1.06/0.52) exceeds the effect. Pod 0 nominated -
residual closest to the fleet mean and clean on criterion 8, so an improvement is attributable to
the mode change rather than to fixing an outlier. Its CR baseline is re-run immediately before
reflashing, same session and pack, and positional is scored against those numbers only.

Also flagged: if reflashing needs the servo removed from the pod, the refit disturbs horn seating
and preload and lands in the before/after. Control for it with a remove-and-refit-without-reflash
baseline, or avoid it entirely if the programmer connects inline on the servo cable.
…-pod scoring

The go-sequence could not have run as written. Three gaps, all closed:

1. The bring-up tool only ever built CoaxialPods. Its pod array is now SwervePod[], PodCal has a
   toSwervePod() that returns either type, and record() branches on the concrete class for the
   instrumentation that differs. PositionalPod reports getLastErrorRad() in the same encoder frame
   so one scorer reads both, and NaN for turn power because it has none - everything downstream
   keying on servo power is meaningless for it by construction, which is why criterion 8 moved to
   holding current.

2. A port declared Servo cannot be fetched as a CRServo; the get throws. During the A/B one port
   is Servo and three are CR, so the tool now acquires both types and every site uses whichever is
   non-null. Without this the tool would have lost PWM enable/disable on exactly the pod under
   test - the toggle the holding-current measurement depends on.

3. Positional pods adopt their measured position at build time, before anything can command them.

Added swerve_positional_p0.xml with ss0 as Servo and pods 1-3 unchanged, and parameterised
SwerveConfigWriter.activateBuiltIn so it can be selected without a Driver Station:
GET /swerve/config?builtin=swerve_positional_p0

ploose.py takes --pod N so the baseline and the retest score the same pod in isolation.

Plan updated: refit control cycle dropped, since the programmer connects through the servo's
existing cable and the servo never leaves the pod. Pod 2's elevated holding current downgraded
from corroboration to hypothesis - pre-registered and directional, but 2 sigma off one 30 s
window, so it waits for longer integration before it carries any weight.
Seam analysis corrected. The window has two ends and the forced traverse happens somewhere in
the overlap arc between them, so the WHOLE arc must clear the dwell set - not a single point, as
the first analysis assumed. That caps clearance at (gap - overlap)/2, and makes overlap width
trade directly against seam clearance:
  200 deg travel, 20 deg overlap -> 13.24 deg clearance
  190 deg travel, 10 deg overlap -> 18.25 deg
Narrowing the travel buys more than reweighting the placement does, so 190 deg it is, with the arc
centred in a strafe-to-X-lock gap at tool frame 18.25-28.25 (forward + 108.25 to + 118.25).
Clearances 18.25 strafe, 18.24 X-lock LF/RB, 61.75 forward, 64.74 X-lock RF/LB. Balanced rather
than biased: at 18 deg the clearance is about 20x the residual and no longer binding, so an even
split is more robust to calibration error.

Dwell gaps are not equal, which the first pass also missed: forward-to-X-lock is 43.51 deg and
strafe-to-X-lock 46.49, from atan2(146.42, +/-154.24). Only two placements achieve the maximum,
not four.

End-stop hazard, absent in CR mode: the travel ends are hard stops and commanding past one stalls
the servo into its overload cutout, reporting nothing. Three mitigations:
  - clamp: every command held 3 deg inside each endpoint, leaving a 184 deg band
  - coverage proof: representations of a heading form a 180 deg lattice, so any interval >= 180
    contains one; 184 leaves 4 deg of slack. verifyCoverage() checks the implementation against
    the real calibration by sweeping the whole heading circle at 0.25 deg, rebuildPods() refuses
    to build a pod that fails, and the worst-case margin is published as posCoverage
  - clamp probe: probeClamp asks 30 deg past each end and reports what was actually written, to be
    run before any step response
noCandidateFault surfaces an unreachable demand rather than letting the pod stop tracking quietly.

Sequence changed to flash twice, deliberately. The band's placement in encoder space is a property
of the reflashed servo, so the seam cannot be measured until after the first flash - and if
re-clocking is needed it must not fall between the baseline and the test. Reflashing is
non-invasive, so: flash, calibrate, seam-check, re-clock if needed, flash back to CR, take the
baseline, flash to Servo again, test. Mechanical state identical across the comparison.
…ility is a hard stop

Two additions, both closing holes that would have produced confident wrong numbers.

Steps 1-2 were ungated. They put the pod into positional mode and drove it to both endpoints with
the band unmeasured, which means the position mapping was fiction - initFromEncoder() would have
mapped the pod's real angle through a placeholder line and commanded an arbitrary position, and in
position mode that is executed immediately. Three guards:
  - PositionalPod writes nothing to the servo while uncalibrated. initFromEncoder() and move()
    both return early. The only path that can move an uncalibrated pod is calGoto.
  - calGoto walks to a target in 0.02 steps with a 0.15 s dwell, checking after each that the
    encoder followed. Two consecutive steps under 1 deg and it stops. The pod's mechanical range
    may be smaller than the servo's programmed travel, and nothing else distinguishes measuring a
    limit from grinding into one.
  - Coverage proof and probeClamp now run after the FIRST calibration as well. A band that has
    been measured but never verified is still unverified.

Band reproducibility is now a hard gate, not an expectation. bandgate.py --save after the first
flash, --check after the second: endpoints must match within 2.0 deg and the span within 1.0 deg,
and it exits non-zero. A shifted endpoint moves the seam by the same amount against 18.24 deg of
clearance, so 2 deg spends about a ninth of the margin; more than that and the first flash's
calibration does not describe the second, the seam analysis is void, and every step response would
be scored against a mapping that does not match the hardware. Silent, and it would look like data.

Also: README said 'Axon Mini MK2'. The servos are Axon MINI+ and it now says so. Worth noting only
one place in the swerve tooling ever named a generation - the original claim of two appears to have
counted the root README's SDK changelog line, which is about OctoQuad hardware and unrelated.
Every other Axon reference in the tooling is generation-neutral.
A request for swerve_positional_p0 was answered with ok=true and an XML body showing ss0 as
ContinuousRotationServo. The activation was actually correct - active read back as
swerve_positional_p0 and the resource does declare Servo ss0 - but nothing in the response said so,
and everything in it suggested otherwise.

Two independent reporting bugs, neither touching the activation path:

  - xml was always SwerveConfigWriter.buildXml(...), the generated configuration for the WRITE
    path, which hardcodes ContinuousRotationServo for every servo. It never had any relationship
    to the built-in resource being activated. That is the CR ss0 that was reported.
  - name was the write path's target, defaulting to DEFAULT_CONFIG_NAME, and was never set from
    the builtin parameter. Hence SwerveBringUp.

So the endpoint described a freshly generated CR configuration while activating a positional one,
and returned ok=true. A success response carrying the wrong content is worse than an error: an
error gets investigated, this gets believed.

Fixed:
  - activateBuiltIn reads activeConfigName() back after setActiveConfig and returns ok=false if it
    does not match what was asked for. Reporting success without checking is what allowed this.
  - describeBuiltIn walks the compiled resource and lists the device type, name and port of every
    declared device. Android keeps res/xml as compiled binary XML so the original text is not
    recoverable, but the device types are the part that matters - it is exactly what distinguishes
    a positional servo from a continuous rotation one.
  - the builtin path now returns that description as xml, and the resource name as name.

Verified on the hub: the activated resource declares Servo ss0 port=0 with ss1-ss3 continuous
rotation, which is what the A/B needs.
PodCal.serialize() stopped at kSBandDeg and had not been extended since. Everything added after
it - the integral bounds, derivative-on-measurement, every pulse parameter, and the whole
positional block - was written to the calibration file never and restored never.

Found by checking the file rather than trusting setPositional's success message: the flag reported
true in state and the file ended ...|0.035|2.0.

The positional fields are the ones that make this urgent. rawDegAtPos0/1 are a physical
measurement obtained by walking the pod to both mechanical limits under stall detection, and they
would have been lost to the next OpMode restart. Worse, positional itself would have reverted to
false, so after restarting into the Servo-configured port the tool would have tried to build a
CoaxialPod on it, failed to fetch a CRServo, and taken the whole drivetrain build down with it -
pods = null for all four, on a pod that had just been flashed.

Now 36 fields, verified round-tripping on the hub. Every one stays optional on read, so an older
calibration file still loads and takes defaults for whatever it predates.
The 36-field check was a snapshot. PodCal.roundTripGaps() makes it permanent: it reflects over
every mutable public field, gives each a value distinct from its default, round-trips through
serialize/applySerialized, and reports anything that does not come back. Any field added later is
covered without anyone remembering to cover it, and an unhandled field type is reported rather
than skipped. SwerveBringUp.init() runs it every session and surfaces failures in state.errors,
because this repo has no test harness and a guard nobody runs is not a guard.

It immediately found one more: discoveredEncoderIndex was never persisted, so the dashboard's
'remapped -> seN' badge vanished on restart even though the remap itself survived in encoderName.
Now persisted as field 37.

Swept the rest of the codebase for state that lives in memory and is assumed to persist. One real
finding: the bring-up tool's heading gains defaulted to 1.75/0.003, the pre-tuning values, while
SwerveDrivetrainConstants has shipped 1.20/0.030 since 2026-08-11. Anyone opening the heading
routine was starting from gains the robot had not used in a week, and would have measured any
change against the wrong baseline. Seeded from the shipped values now. Still not persisted, and
that is the right answer - FollowerConstants is the source of truth and a saved copy would just be
a second place to disagree.

The remainder are session-scoped by nature and now documented as such rather than left ambiguous:
xLock and headingHold are view toggles, and the servo PWM range and enable are controller-side
settings the Robot Controller resets on restart. That last one has a trap worth the README line -
a non-default PWM range silently reverts, so a measurement taken after a restart is not the
measurement that was set up.
Completes the previous commit - this half was dropped when its patch missed on an exact-text
match while the Java half applied, so the code changed and the documentation did not.

Records the reflective round-trip guard, and lists the state that is session-scoped on purpose so
it reads as a decision rather than an oversight. The PWM line is the one with teeth: a non-default
servo PWM range reverts silently on a Robot Controller restart, so a measurement taken afterwards
is not the measurement that was set up.
…y go stale

The bring-up tool keeps its own heading kP/kD for live experimentation and does not read
FollowerConstants. That copy sat at the pre-tuning 1.75/0.003 for a week after the shipped value
moved to 1.20/0.030, so anyone opening the heading routine was measuring against a baseline the
robot had stopped using - and would have drawn conclusions from the difference.

The tool's default is now seeded from the shipped value, but that only fixes today. The note goes
next to headingPIDFCoefficients, which is where someone changing the gains actually looks, rather
than next to the copy, which is where they do not.

Out of scope to retune heading here; this is only about the two numbers agreeing.
Traverse frequency is width-independent and my 1.9x claim was wrong. Position tracks demand one
for one, each traverse removes exactly 180 deg of accumulated position, and position is bounded by
the window, so over a sweep of D degrees the count is D/180 for any width. A jump lands 180 deg
from the edge it left, not at the far end of the window - at 355 deg the pod cycles 175 to 355 and
never uses the lower 175. Width buys hysteresis and nothing else.

So the width is decided by the encoder, not the control law. The band occupies that much of the
encoder's 360 deg and the remainder has to hide the Axon's non-monotonic wrap region: 270 leaves
90 deg of cover, 355 would leave 5. A boot-time read landing in the wrap does not merely add noise,
it returns a wrong angle, and in position mode that drives the pod a long way under torque.

270 deg, Servo Angle 194 on the programmer's 0-255 to 0-355 scale. Overlap 90 deg, 84 after the
end clamps, resolution 0.15 deg/step - 7x finer than the 1.0 deg target.

One consequence in our favour: the selection rule is greedy, so the pod only jumps when cornered
and traverses land at the two ends of the clamped band, separated by the clamped width mod 180.
At 264 deg that is 84, which puts the two traverse headings in different dwell gaps and reaches
about 20 deg of clearance - better than the 18.24 available at 190 deg, where both landed within
one 10 deg arc. seam.py now computes this properly instead of assuming the arc-in-a-gap model, and
analyses the clamped band rather than the programmed travel, since that is where traverses happen.

initFromEncoder now takes the median of five reads spaced 4 ms apart and refuses to command if
they spread more than 2 deg - forty times the 0.05 deg noise floor, so only a genuinely bad read
trips it. It returns a boolean, move() will not command without a trustworthy starting position,
and a refusal is surfaced in state.errors rather than leaving a pod silently uncommanded. Wrap
exclusion makes a bad read unlikely, not impossible, and the check costs 20 ms once.
…t no-op replace

"Missing turn servo ss0" on INIT with the positional config active. The hardware map was correct -
the RC log shows Servo ss0 alongside three ContinuousRotationServos - and the calibration was
correct, positional=true having survived in the file. The fault was mine, in acquireHardware.

The Servo fallback was never in the built code. posServos was declared, referenced in ten places
and populated in none; all three consumer sites got their non-null guard, the producer did not.
The patch that added it searched for 'servos[i].setPower(0);' which an earlier edit had already
replaced with 'setServo(i, 0)', so that one s.replace() matched nothing and did nothing while the
rest of the script applied. No assertion, no output, no failure. Exactly the class of bug the
atomic patcher was written for, from a script that predates it.

Fixed, and made louder:
  - both device types are tried and whichever answers is kept
  - the error names both attempts and quotes both exception messages. "Missing" was actively
    misleading when the device was present and simply of the other type
  - a pod marked positional whose port did not resolve as Servo now says so explicitly, and so
    does the reverse case

Also fixed while in here: PodCal.roundTripGaps() ran before acquireHardware(), whose first act is
hwErrors.clear(). The persistence guard's output was being discarded unread every single init -
a guard that cannot report is not a guard. Moved after.

Swept SwerveBringUp, PositionalPod and PodCal for other fields with consumers and no producer,
which is the signature this bug left. None found.
calGoto walked outward from posServos[i].getPosition(), which is the controller's last COMMANDED
position, not a measurement. After a restart that is a default with no relationship to where the
pod physically sits, so the first 0.02 step would have been an absolute command to an arbitrary
place and the pod would have crossed up to half its travel in one move. The incremental walk with
stall detection protected every step except the only one that was actually dangerous.

calHome makes that move explicit and bounded instead of incidental: it commands mid-travel, which
is at most half the travel from anywhere in the band, with Soft Start limiting the speed. It
records the encoder before the move, and it establishes calPos as a true starting position so
every subsequent walk is honest. calGoto now refuses until it has run, and setPositional clears
the homed flag because a rebuild or a port change invalidates it.

Nothing removes this move - no method exists to learn where a position-mode servo is before
commanding it somewhere. It can only be made small, slow, expected and observed.

Two reporting fixes found while reading the state back:
  - posCoverage published NaN through fmt(), which renders 0. A coverage margin of zero reads as
    alarming and wrong when the truth is that pods had not been built yet; now null.
  - hasServo reported only the CRServo array, so a correctly working positional pod read as
    hasServo:false. Now true for either device type, with servoType naming which one.
…verified clean

Decision 2026-08-12: the drivetrain is continuous-rotation only. The code stays committed
because the design was sound and the analysis is worth keeping; it is disabled so nobody reaches
it by accident.

PodCal.POSITIONAL_ENABLED is a compile-time false. While it is false toSwervePod can only build a
CoaxialPod whatever the per-pod flag says, and setPositional refuses with a pointer to the write-up
rather than silently setting a flag that does nothing. Reviving it needs a source edit, which is
the right bar. The competition path was already clean - neither SwerveDrivetrainConstants nor
DriveTeleOp has ever referenced PositionalPod, and PodCal.toSwervePod is the only construction
site.

POSITIONAL_SHELVED.md records what it costs, which is what the decision turned on rather than any
failure:
  - a 180 deg traverse, about 260 ms with the wheel pointed wrong, once per 180 deg of demand
    sweep and independent of window width. CR mode rotates continuously and never traverses.
  - stick-slip inside the servo's own loop: calHome measured a 0.38 s dead stall followed by a
    25.4 deg break at 390 deg/s. Positional does not escape stiction, it absorbs the break better.
  - permanent maintenance surface on a student-maintained robot: a firmware flash per servo, a
    config port type, a two-point endpoint calibration, a homing move before every calibration,
    seam placement derived from chassis geometry, an encoder wrap that must stay outside the band,
    and band reproducibility across flashes. None of it hard once; all of it forever.

It also records what was promising and is now unmeasured. The one move observed in detail landed
with no overshoot and held 0.34 deg peak-to-peak at sd 0.067, against 0.77 deg mean and 2.36 worst
case for CR. There was never a scored step trial or an A/B, so this was shelved on cost, not on a
result, and the document says so plainly.

The plan and the config XML carry SHELVED banners; the XML says what breaks if it is activated.
The bring-up tool holds its own gains, which is what it is for, but nothing ever compared them
against what the robot ships with. Its calibration file sat at kD 0.010 while
SwerveDrivetrainConstants said 0.022, and every measurement taken through the tool in that window
was at a configuration nobody had chosen. Silent.

Reported, not corrected, because the tool legitimately needs to differ. The shipped turn gains are
now public constants, state publishes them under 'shipped', and any per-pod difference in kP, kD,
kS, kS band, cache, kF or kI appears in errors[] naming both values. Recomputed every publish, so
it clears itself the moment they agree again. Verified both ways on the hub.

Audited the 477 logged step trials while here: zero label-versus-recorded mismatches. step_trial
captures the gains in force from live state before each run, so the trial log records what was
actually set rather than what was intended, and every sweep, grid, gate and A/B result stands.

One result was affected. crit8_current.py calls pidStepAll against current state without setting
or logging gains, and it ran directly after ploose.py left the tool at kP 0.20 kD 0.010 kS 0.035.
The effect is negligible - at rest de/dt is zero so kD contributes nothing to holding current, and
kP and kS matched - and the measurement was built for the positional comparison that is now
shelved. For CR, criterion 8 is resting servo power RMS, which the step scorer computes on every
trial and which the audit covers.
…y about 3x

Robot on carpet at competition weight, 12.6-12.9 V, gains matching shipped (kP 0.20, kD 0.022,
kS 0.035/2 deg, cache 0.01).

Step battery, 10 repeats each:
  step    |ss| mean   |ss| max   settled   rings max   pp max
   15        1.12       3.21       32%        10        1.02
   45        3.03       7.55       25%        11        1.58
   90        2.92       7.37       28%         9        1.80
  135        2.43       6.39       32%         9        2.14
  90 on blocks, same gains: 0.77 mean, 2.36 max, 97% settled
135 measured 38.1-45.8 deg of travel, confirming the flip resolves it to about 45.

Diagnosis, and it is a single parameter. kS 0.035 was fitted to the OFF-GROUND breakaway. Carpet
scrub raises the load, so breakaway rises and the pod parks further out. Back-solving from the
observed residual: at 2.92 deg the loop produces kP*e + kS*tanh(e/band) = 0.042, and at the 7.37
worst case 0.061, against 0.035 off ground. Carpet breakaway is roughly 1.2-1.7x higher and kS no
longer covers it.

This also corrects the team's standing note that carpet hides the problem. Carpet damps ringing -
ring counts are comparable - but it worsens residual, because friction is what the residual is
made of.

Drive tracking, steady state with the first 0.6 s of each burst discarded, |err| mean/max deg:
  rotate 0.25   11.3/30.5  12.3/29.5   9.6/21.7   7.1/19.0
  rotate 0.60    9.2/23.5  13.0/33.2   9.5/23.3  12.4/28.9
  forward 0.12  10.3/19.4   7.3/17.2   6.5/21.8   9.2/22.0
  strafe 0.12    6.3/28.5   3.7/31.5  10.3/28.4   5.7/20.5
  diag 0.12     10.7/23.7  10.1/27.3   4.2/14.8   8.6/19.6
Pods lag their commanded angle by 4-13 deg under load, 2-4x the static residual, and high power is
no worse than low.

Rapid reversals: the flip logic is correct. Target alternates 90/270 while the wheel stays at
70-120, one flip transition per reversal, no chatter and no 180 deg thrash. Tracking oscillates
about +/-20 deg during the sequence.

Zero input, both modes correct. X_LOCK snaps all four to the X within 1.9 deg, quiet at 0.11-1.57
peak-to-peak, holding at 0.015-0.026 servo power - pod 1 at 0.0262 is at the low end of measured
breakaway, so it is working while parked. IGNORE_ANGLE_CHANGES holds the driving heading with
servo power exactly zero.

Epsilon finding: DriveTeleOp deadbands the stick at 0.05 and the follower's epsilon is also 0.05,
so there is no hysteresis between 'stopped driving' and 'engage X_LOCK'. An input straddling that
boundary thrashes the pods 63-95 deg as they alternate between the X and the drive heading. At rest
the stick reads a clean zero so X_LOCK is stable; the exposure is a driver holding a very slight
deflection.
…tivated it was wrong

Two things, and the second matters more than the first.

The diagnosis was wrong. I attributed the carpet residual to higher breakaway and predicted carpet
breakaway of 0.042-0.061 by back-solving. Measured directly on the ground at competition weight:
0.025-0.050, mean 0.036 - identical to off-ground. Carpet does not raise the breakaway threshold
at all.

What carpet does raise is kinetic friction. At 0.050 power a pod travels 1.7-23 deg per 0.45 s
dwell on carpet against 43-48 off the ground. So the pod is not parked below a raised threshold,
it is stopped earlier in its approach by drag: off the ground it coasts to within 0.77 deg, on
carpet it halts at 2.9. Confirmed that it is genuinely stopped rather than still creeping - mean
|err| across 40 pod-runs is flat from t=1.0 s (3.54 deg) to t=4.8 s (2.93).

The fix still works, for the corrected reason: more static-friction authority pushes further into
the approach before drag stops it. Carpet sweep at 90 deg, randomised, band 2.0 throughout:
  kS 0.035 (current)  |ss| 2.92 mean / 7.37 max   rings 1.57   pp max  1.80
  kS 0.040            |ss| 2.25 / 5.78            rings 2.88   pp max  9.11
  kS 0.045            |ss| 1.48 / 4.59            rings 2.38   pp max  3.04
  kS 0.050            |ss| 1.40 / 10.09           rings 6.47   pp max 41.72
  kS 0.055            |ss| 1.48 / 6.83            rings 15.29  pp max 54.14
Narrow bands were tested and are worse: 0.050 at band 1.0 gives pp max 50.27. Wide band with a
higher peak beats a sharp taper, which is the opposite of what I predicted.

kS 0.045 band 2.0 roughly halves the residual against the current set. It still misses criterion 5
at 1.48 deg mean against 1.0, and the leading pair needed n=40 to separate - the 6-repeat numbers
for 0.045 were 1.11/2.71, optimistically low.

NOT yet applied to SwerveDrivetrainConstants. kS 0.045 was measured off the ground earlier at
rings 12.06 and pp max 31.32, so a carpet-fitted kS is likely to violate criterion 7, which asks
for the same gains to be clean on both surfaces. That is a judgement call about how much venue
friction varies, and it is the user's.
…se off it

Same gains otherwise (kP 0.20, kD 0.022, cache 0.01), randomised, 10 repeats each, 12.5 V.

Off the ground, n=40 pod-runs each:
  kS 0.035   |ss| 0.56 mean / 2.22 max   set 85%   rings  1.40   pp max  9.00   loose  1/40
  kS 0.045   |ss| 1.23 / 7.26            set 55%   rings 11.43   pp max 49.31   loose 13/40

On the game surface, from the carpet sweep:
  kS 0.035   |ss| 2.92 / 7.37            rings 1.57   pp max 1.80
  kS 0.045   |ss| 1.48 / 4.59            rings 2.38   pp max 3.04

So the two settings invert between surfaces. 0.045 halves the residual on the surface the robot
competes on, and off the ground it draws the loose mode in a third of runs at 49 deg peak-to-peak.
This confirms the earlier off-ground reading at the current kD rather than the kD 0.020 it was
originally taken at.

Worth separating two things that both get called the loose mode: on the game surface at kS 0.045
the worst peak-to-peak is 3.04 deg, so its 4/40 loose runs are a small wiggle. Off the ground the
same setting reaches 49 deg. They are not the same failure.

The tool-versus-shipped divergence guard fired correctly and unprompted at the start of this run,
reporting kS 0.040 against a shipped 0.035 left over from the previous sweep. Working as intended.
… pass

The carpet re-fit gives kS 0.045, which halves steady-state residual on the
game surface (2.92 -> 1.48 deg mean, 7.37 -> 4.59 max) and is much worse off
the ground (0.56 -> 1.23 mean, 1/40 -> 13/40 loose, 9 -> 49 deg worst p-p).
The two settings invert between surfaces, at the current kD, so this is not
an artefact of the earlier kD 0.020 sweep.

Shipped value stays 0.035 rather than taking the better carpet number. A
mechanical pass is planned, and lubricating the steering path moves the plant
toward the off-ground condition - the exact regime 0.045 fails in. 0.035
holds across the whole friction range measured so far. Re-fit after.

Both cells and the reasoning are in the constants comment so the number is
not lost, and in trials.jsonl. Nothing is measured at these gains yet beyond
what is quoted; criterion 12 still has to run at whatever ships.
…iving

The bring-up dashboard offered kP/kI/kD/kF only, so kS - the coefficient this
week has actually turned on - could be changed from the Python tool but not
from the UI, and there was no way to see what the robot was holding. The
backend already accepted ks, ksband, cache, kilimit, kiband, kireset, dom,
pulsed and the pulse params; only the front end was short. All of them are
now on the panel, mirrored from pod state, dirty-tracked so polling cannot
overwrite typing, and applied per pod or to all four.

Corrected the panel's advice while there: it told you to raise kF until the
pod overcomes stiction. kF is a sign relay, not a feed-forward, ships at 0,
and kS replaced it.

Per-pod cards keep the four-term subset deliberately - seventeen fields on
four cards is not a tuning surface. Select a pod and use the main panel.

drivecapture.py records continuously in chunks while a human drives, since
the recorder holds 3000 samples and stops rather than wrapping. First cut
polled recorder state before recStart had drained and read the pre-start
snapshot as "already finished", so every chunk closed after one sample; it
now waits for the run label to come back before trusting the flag.
…l has no stiction

The driver's wiggle-while-driving and the X-lock hunt were one mechanism,
and it was never the gains: a ROLLING wheel steers nearly friction-free.
With the servos frozen mid-drive, free-castering wheels held a 0.1-0.4
degree band - the mechanics are stable and the encoders read clean. But
gains sized to break parked stiction (kP 0.38-0.44 plus kS) are several
times over-gained on that near-frictionless plant, so the closed loop
limit-cycled at 3-4 Hz and 25 degrees on every pod, at every speed, immune
to kP, kD, kS, dom, and loop rate - invisible to every static tuning
session this project ever ran, because static steps hold drive power at
zero. kP 0.14 while rolling collapsed the oscillation; kP 0.38 is right
when parked. That is not a compromise problem, it is gain scheduling.

CoaxialPod now scales the whole turn effort (PID and kS both) by two legs,
whichever demands more reduction: commanded drive power (robot translating,
wheels rolling) and the pod's own measured steering speed - the at-rest
hunt sustained itself by keeping its own contact patch moving, so
amplitude-dependent gain starves it: swing fast, gains drop, energy drops,
stiction catches it. Floor 0.32 of full authority, reached by 0.25 drive
power or ~260 deg/s of pod swing.

Measured, podhold.py (hold = release-to-X-lock, steady = mid-dash):
  steady-drive pod error   33-89 deg p-p  ->  3-8 deg p-p, mostly 0 rev/s
  X-lock hold              hunting indefinitely, act-energy 6-37
                           ->  actuation-silent, 0.0-2.0, three trials
With the schedule providing parked aggression on demand, one gentle recipe
wins on every pod: kP 0.38 / kD 0.022 / kS 0.022 / band 2.0, shipped in the
per-pod arrays.

Kept but disabled, with their measurements: a median-of-3 on the raw angle
and an output slew limiter - both made the oscillation WORSE (wheel spans
tripled), because this lag-dominated loop punishes any added lag, which is
also why kD never helped. The one honest exoneration along the way: the
"encoder corruption" implied-rate spikes were mostly real motion - the
servo out-runs its travel-averaged speed figure.

Residual: a seeded oscillation still appears occasionally at floor gains
(one steady trial in three); candidates are a lower floor or hardware
(feedback wiring) - next session, fresh battery.
… transitions keep their snap

Two driver requests and one honest open question.

The heading drifted a little while translating because sub-epsilon
corrections die inside arcadeDrive: |rotation| < 0.05 is treated as zero,
so at heading kP 1.2 any error under ~2.4 degrees produced a correction
that was silently eaten - the robot drifted exactly as far as the epsilon
shadow. The soft lock floors a worthwhile correction (error > 1 degree)
past epsilon so it reaches the pods, commands genuinely zero rotation
inside the deadband, and heading changes still come only from the stick.
Measured while translating: drift 0.3-1.4 degrees typical, 3.4 worst,
against 2.4-plus before.

The gain schedule gets an error gate at 20 degrees: the limit cycles it
suppresses all live near the target, but scheduling down a pod mid-slew
made X-to-forward convergence so slow the robot crabbed 50 degrees off its
commanded direction until the pods caught up. Above the gate the gains are
simply the gains. Verified in the trace: X-parked pod slews 47 degrees in
0.3 s at full authority, then tracks at 1.9 degrees error and 0.02 power.

drivecheck.py runs the full simulated-joystick sweep - 8 translation
directions, both rotations, both arcs, every leg bounds-checked and
recentering when room runs short. All directions move with correct sign
and axes; rotation sweeps clean both ways.

Open, needs a physical ground truth: the steady-window motion direction
reads ~15 degrees rotated from the heading-frame command, CONSTANT in the
field frame across travel directions - which pattern points at the
odometry frame itself (pinpoint/pod mounting rotation) rather than the
wheels, and the zeroTrim was derived FROM that same odometry. One
tape-line drive decides which frame is lying.
…ation statistically gone

The schedule's floor, velocity threshold and error gate are settable
through setPidf (floor/velstart/gate) so a vibration hunt iterates without
a reflash. Swept floor 0.32 vs 0.24 on a fresh pack, three full trials
each, plus a confirmation:

  steady-drive wheel reversals: ZERO in all 24 sweep pod-dashes
  steady dither typically 0.1-0.4 deg p-p (best trials 0.0-0.11), with an
  occasional rough episode (7-19 deg p-p) in roughly one dash in five -
  the seeded residual, now rare instead of constant
  X-lock holds effectively silent everywhere (act energy 0.0-3.4)

Floor 0.24 bought nothing over 0.32 and added hold micro-activity, so the
shipped default stands. The err_rms figures that look alarming next to
near-zero p-p are a CONSTANT tracking lean under drive load - an offset,
not a vibration; the physical straightness was confirmed against a tape
line.
… with hysteresis

The driver reported the hold not working, and the measurement agreed with
the feeling: a bare 0.06 correction floor holds heading to a degree at
crawl but lets it wander 3-9 degrees at 0.55 power - yaw disturbance grows
with speed while the floor stayed flat. The correction floor now scales
with translation magnitude up to 0.20, engages past 1.2 degrees of error
and releases below 0.5 (hysteresis, so the trim never dithers on its own
deadband). Measured at 0.7 power: heading back within 0.66-0.89 degrees by
leg end. The remaining transient is the launch kick - full power stepped
through X-parked pods yaws the chassis for the ~0.3 s the pods take to
swing around, then the trim reels it back.

Also: the robot-side stick threshold rises 0.02 -> 0.055 to match the
browser deadband, so a slightly leaky pad axis can never silently sweep
the setpoint the dashboard would have zeroed.
… on its own boundary

Full-speed diagonals with heading as the wobble meter (diagtest.py - the
driver's suggestion, and the right one: pod asymmetries become yaw jitter
and the IMU does not share the pod encoders' noise) found the mechanism the
straight-line work never could. A diagonal demand from X-parked pods sits
almost exactly 90 degrees from some pods' positions - precisely on the
flip boundary, where move() re-decides every single loop between "go to
theta" and "go to theta+180 reversed". The pod dithers, the wheel sweeps
60-110 degrees, and four pods doing it asymmetrically wobbles the chassis:
heading span ~8 degrees mean with 1-2 reversals per run. The visible pod-0
shake was this.

The flip decision now carries +/-10 degrees of hysteresis: inside the band
the previous choice stands, at worst accepting a 100 degree rotation
instead of an 80 degree one, once. Measured on the same diagonals: heading
reversals 1.2 -> 0.2 per run (mostly zero - the chatter is gone), span
mean 8.0 -> ~5, best runs 1.2 degrees, pod-0 no longer an outlier.

What remains at 30+ in/s is smooth one-way drift, up to ~9 degrees on the
fastest runs: disturbance-driven, trim-authority-limited. Raising the trim
cap to 0.32 was tried and rejected with data - at 0.8 translation it
swings the pod demands hard enough to become the wobble it fights (spans
16, reversals 3). The 0.20 cap stands; the deeper fixes are per-pod lean
feed-forward or a heading retune under translation, another day.
…ate stream

The driver still feels wobble and drift that scripted runs may not
reproduce - human hands find regimes harnesses do not. The recorder grows
seven global columns (heading, hold target, field pose, and the drive
command actually applied, ct being the heading-hold output rather than the
raw stick), so a felt episode lines up against its causes in one
synchronized loop-rate stream.

sessionrec.py chains chunks for as long as the driver drives, with the
run-identity handshake that drivecapture learned the hard way;
sessionreport.py sweeps a session for DRIFT episodes (heading off the hold
target while translating, rotation stick quiet), WOBBLE episodes (yaw
reversal rate over sliding windows) and per-pod chatter, each stamped with
what the sticks were doing at the time. Verified end to end on a scripted
mini-session: columns flow, and a clean drive reports clean.
…ded human session

The driver's recorded session held the number every scripted test missed:
humans drive this robot at 0.05-0.20 power, and all the harness work ran
0.3-0.8. In that band the old schedule (floor by 0.25 drive, velocity leg
from 40 deg/s) left the pods in an untested intermediate zone, and the
at-rest dither - about a degree of amplitude, peaking near 30 deg/s - flew
clean under the velocity leg. That was the felt wobble and the audible
low-speed chatter.

Re-fit with a driver-mimic harness (same low-power stop-and-go pattern,
scored by sessionreport's episode sweep): floor 0.24 reached by 0.10 drive
power, velocity leg from 22 deg/s. Same-pattern episodes: wobble 33 -> 8,
pod chatter 76 -> 8, drift 6 -> 1 in the best like-for-like runs; 60-90%
reductions across seeds. Floor 0.28 was tried and rejected - it gives back
the slow-regime win without measurably helping fast runs, whose spread is
run-to-run stochastic. Fast diagonals sit in the same ~5-8 degree band as
before within that noise, transitions still get full authority through the
error gate, and the schedule ramp is now a runtime knob (setPidf ramp=)
like the rest.
… own pulse size

One pulse size cannot straddle this plant. Measured travel per 20 ms pulse
spans 0.05-0.5 deg across pods at one power, the stiction cliff makes it
bimodal (stuck barely moves, unstuck flies), and friction drifts as pods
warm. Fixed 0.055 ping-ponged (travel 4x tolerance); fixed 0.035 starved
on the stiff pods (21 pulses/3s, no progress). So: base pulse sized for
the loosest case, and any same-direction pulse that fails to shrink the
error by 25% (floored at 0.35 deg - noise must not fake progress) grows
the next one 1.5x, capped at 0.13 - above the stiffest pod's measured
breakaway (0.106), because a cap below breakaway rides forever. Overshoot
or convergence resets to base. Stall history dies only on real motion:
resetting on band exit let border chatter wipe the ladder every loop.

Steps land at 0.35 mean / 0.81 max deg warm (continuous: 1.3 / 4.5) and
the servo is silent after convergence (rest RMS 0.002 vs 0.016).
…sion's evidence

pulsedab.py measures the two static regimes (X-lock rest silence, step
cells with pulses on/off); pulsecal2.py re-measures travel per pulse on
the current plant - it caught 15 ms as PWM-frame-quantised (avoid) and
sized the base pulse at 0.035/20ms. Raw chunks for the whole arc: ping-
pong, starvation, escalation, ladder-reset fix.
…nd a frame check on the saved box

Every moving instrument read 5-10x worse than yesterday in BOTH pod
configs - all four pods erroring in correlated antiphase. That signature
is yaw injection, not pod trouble: the soft heading lock's speed-scaled
minimum-magnitude floor relay-oscillated at 3.5 Hz / +-0.135 turn (10
deg of fishtail at 0.3 power). Rate-gating the floor only slowed the
relay - each kick is a fixed yaw impulse far beyond what a degree of
error needs, so overshoot re-arms it at every swing turnaround. The
arcadeDrive 0.05 rotation epsilon wants a BYPASS, not a floor: shift the
PID output past epsilon so authority is continuous from zero and the kD
damping survives to the output. Diagonal heading spans 7.6-9.5 -> 3.5
deg mean, steady pod err_rms 26-48 -> 4-14.

Also: a box reloaded from disk now proves its frame is alive before it
counts as a fence. The Pinpoint keeps its frame across OpMode restarts
but zeroes on power-up, and a freshly booted unit reads exactly
(0,0,0 deg) - so triple zero at first pose discards the stale box
instead of enforcing coordinates that no longer mean anything. Deploys
stop costing a re-mark; power cycles refuse to lie.

And podhold's steady instrument drives field-frame now: robot-frame
forward with a field-frame exit test measured fence-fighting whenever
heading was rotated from the box axis.
… better everywhere else

The moving A/B on a healthy heading baseline: diagonals 3.5 vs 3.6 deg
span (tie), steady and driver-band bursts within run-to-run noise, and
at rest pulsed goes actuation-silent in 0.03 s where the continuous
loop scrubs indefinitely (act energy 0.0 vs up to 3.1, several pods
never quiet a full second). Static steps land 0.35 mean / 0.81 max deg.
buildPod now enables it for every OpMode built from these constants:
band 6 deg, tolerance 0.6, base pulse 0.035/20 ms, stationary gate
20 deg/s, coast 100 ms. Evidence chunks and instrument logs attached.
…ive-heading rotation

The toggle lives in the dashboard gamepad section. Ticking it captures
the robot's current heading as away-from-the-driver (re-tick to
re-square), and every drive command the pad loop sends carries foc=1.
The frame is per-command, not robot state: bench scripts never send the
flag, so their robot-frame commands cannot be double-rotated no matter
what the checkbox says. The rotation happens robot-side against LIVE
heading each loop rather than at the 10-17 Hz command rate - a spinning
robot turns several degrees between stick updates, and a stale transform
drags the translation direction with it. Dead heading sensor while
field-oriented stops translation instead of guessing a frame.

Verified on the mat: forward motion held the same ground direction
before and after a 60 deg chassis rotation (delta 1.1 deg), strafe reads
90 deg left of forward. The recorder's cf/cs columns now log the
post-fence, post-rotation robot frame - what actually drove the pods.
…rc-tracking gap

The driver's recorded session caught what every bench instrument missed:
median pod tracking error of 8-14 deg through turns, worst on stiff pod
0. Inside the pulse band the only actuator is coast-separated pulses -
at most ~5-10 deg/s of correction - and an arcing demand rotates faster
than that, so each pod lagged to the band edge and rode it. Straight
lines and parking never showed it because their demand holds still,
which is exactly why the steps, steady runs and diagonals all came back
clean. Pulses now engage only below 0.08 drive power: the scheduled PD
(built for the rolling plant) tracks moving demands continuously, the
pulse ladder (built for stiction) owns the approach to rest. Static
cells after the gate: 15 deg steps land 0.24 mean / 0.52 max deg, post-
settle pp 0.23 deg, rest RMS 0.0004 - the parked regime kept everything.

The session chunks ride along, including the rescued post-fix drive
(sessionrec interrupted mid-chunk again - the buffer rescue works).
…e export

The operator re-zeroed all four pods (ss0/ss3 flipped drive direction
with their ~180 zero moves). The pasted dashboard export turned out to
be 33-104 degrees stale against the hub - generated mid-recalibration -
so the arrays carry the /state full-precision values the robot actually
drives with. Ranges, gains, kS, caching, pulsed setup: all unchanged and
already matching. Standing caveat moved into the comment: the old zeros
embedded a +13.13 deg odometry-derived trim; whether this re-zero has a
collective bias is unverified until the crawl-speed crab check is redone.
…clears the re-zero

Correction of the previous sync. The /state zeroDeg field is live
telemetry that moves with wheel position - offsetDeg is the stored
zero - and the previous commit put zeroDeg snapshots into podZeroDeg.
The operator's pasted export matched /sdcard/FIRST/swerve_bringup_cal.txt
all along; the array now carries the file's radians at full precision.
Sync calibration from the cal file or the export, never from telemetry.

Crawl crab check on the new zeros (six 12 in legs, both directions):
mean -3.1 deg, median -1.8, spread 9 - no collective bias worth
trimming (the 2026-08-14 trimmed set verified at ~2.5 equivalent).
Position frame and IMU heading agree within ~2 deg, which retires the
"15 deg odometry rotation" mystery as the old zero-set's bias.
Creep note: below ~0.18 command the drivetrain sits at rolling
breakaway (0.15 crawls at 2-3 in/s, 0.20 runs 11.5 in/s).
…anslation

The driver's report - every ~3 s at slow forward the pods go wobbly and
return - reproduced exactly at 0.16 power. Turn deflects pod demands by
atan(turn/translation), so the same 0.055+PID assisted correction that
is invisible at speed swings every pod 30-40 deg at crawl (wheels chased
to +-50), the swing scrubs the chassis, and the correction relays itself
for a second before the error lands back in the deadband: quiet drift,
wobble burst, return. The bypass now stays out of the mix below 0.25
translation; the raw damped PID runs instead, and arcadeDrive's own
0.05 epsilon makes crawl corrections fire only past ~2.4 deg at the
gentlest magnitude the mixer can deliver. Same leg after: wheel
excursion +-9.9 deg worst (was +-50), heading bounded at 3.8 deg.
… corrects continuously

arcadeDrive's shared 0.05 epsilon swallowed every fine heading
correction, and the entire soft-lock saga - floors, rate gates,
bypasses, crawl gates - was scaffolding around that wall, each piece
forced to kick at 0.05+ minimum, which at crawl deflects pods 17-40 deg.
Rotation now has its own 0.015 epsilon (translation keeps 0.05 - the
zero-input/X-lock path is tuned around it), and below 0.25 translation
the raw damped PID runs continuously with nothing zeroing it.

Cruise at 0.16 forward, same instrument, three generations:
original bypass 66 deg worst wheel excursion (ct to 0.130); crawl-gated
bypass 9.9 (ct to 0.050); continuous PID 0.3 deg (ct max 0.014).
Heading span over a full crawl leg: 4.8 -> 1.1 deg. Speed regime
(>=0.25) keeps the proven deadband+bypass unchanged. Shared Swerve.java,
so auto and teleop inherit the lowered wall automatically.
The "heading drift at the start" of the driver's recording was the robot
being picked up and repositioned: pose jumping 10-20 in per tenth of a
second with wheels parked and zero commands, the IMU faithfully
reporting the hand-rotation, X-lock declining to fight it (by design)
and the hold target re-adopting on every put-down. The controller was
innocent - but the lifted pods integrate garbage, so the box marked
minutes earlier no longer matched the mat. Odometry speed above 80 in/s
(the drivetrain tops out around 45) now discards the box on the spot
and says why. The rescued session chunk rides along as the evidence.
Two host-side scorers, and the first look at the archive with them.

steerqual.py scores a recorder CSV against all eleven criteria and draws the
six verification graphs. jumpcause.py replays the demand chain from the logged
applied command and attributes every setpoint discontinuity to a branch of the
mixer - the replay matches the logged targets to 0.0006 deg over 1731 samples,
so the attribution is arithmetic rather than inference.

What that says about mydrive-001 (71.9 s, tiles, 12.37 V, human driving):

  wheel path / commanded path   0.89 - 1.01   (was believed 1.7 - 3.0)
  wheel reversals/s             4.17 - 4.91
  COMMANDED reversals/s         3.47 - 4.17   (was believed 0.41 - 0.53)

The pods are tracking a demand that is itself reversing four times a second.
The 2026-08-13 diagnosis - a hunting closed loop - cannot be right, and no
amount of pod gain tuning addresses a shaking setpoint.

601 setpoint jumps over 15 deg while driving, attributed:
  42% rotation epsilon wall, 19% X-lock engage/release, 21% heading-PID
  jitter amplified by atan2 at low translation, 10% per-axis deadband,
  5% deliberate flips, 3% translation epsilon wall (but those are the
  biggest: mean 74-95 deg, max 179), 0% box fence.

Also refutes the standing publish() hypothesis. Sampled live with the robot at
REST in DRIVE - no servo or motor writes at all - publish still costs 36.67 ms
(32.31-39.58, n=60). It cannot be blocking behind actuator writes on the Lynx
bus. It is CPU, and the only heavy thing in there is ~1000 String.format calls
per publish, 780 of them from the 260-sample trace.

FINDINGS.md and ASSUMPTIONS.md carry the full record, including the six places
CLAUDE.md is now out of date.
…gets a stopwatch

Measurement only - no control behaviour changes.

SHIPPED. DriveTeleOp has never had its loop rate measured with 1/mean(dt) and
no capture of the competition path exists at all, so every steering conclusion
in this project came from a different OpMode with a different publish path.
TeleLoopProbe (diagnostic class, three lines in the OpMode) adds a 1 ms-bin dt
histogram, the same PodRecorder the bring-up tool uses, and a /swerve/state
snapshot. The web routes are registered by @WebHandlerRegistrar at app start
and serve whatever OpMode published last - they were never bring-up specific -
so drivecapture.py and every host scorer now work against DriveTeleOp
unchanged. Pod reads are bulk-cache hits, the battery is sampled at 2 Hz off
the ADC, the snapshot is built at 20 Hz, and TeleLoopProbe.ENABLED turns the
whole thing off.

SHIPPED. SwerveDrivetrainConstants.builtPods exposes the pods the Follower
builds, because the Follower has no accessor and there is otherwise no handle
on the competition drivetrain. CoaxialPod.getLastTargetWheelRad() reports the
demand from inside the pod.

DIAGNOSTIC. publish() now times itself in six sections and counts its own
fmt() calls, and setFastFmt switches between String.format and a hand-rolled
formatter at runtime - so the two can be interleaved in one session against
one battery instead of across a redeploy that would change the JIT state, the
trace length and the battery all at once.

DIAGNOSTIC. computeTargets was a mirror of the mixer and had drifted: the
mixer moved its rotation epsilon to 0.015 on 2026-08-15 and the mirror kept
0.05, so for |rotation| in [0.015, 0.05) the recorder logged a demand the pods
were never given - 4.0% of mydrive-001, worst 15.0 deg, in the column that is
supposed to discriminate demand shake from response shake. Epsilon synced, the
X-lock engage delay mirrored too, and a p{i}_ctgt column added that reads the
demand out of the pod so no mirror can drift again. tgt stays, so the whole
archive remains comparable.
SHIPPED - DriveTeleOp. The translation deadband was per axis, which does not
shorten a shallow-diagonal command, it ROTATES it: the smaller axis is zeroed
and the direction snaps to the nearest cardinal, which every pod's atan2 then
follows. Measured on the equivalent 0.06 deadband in the dashboard: 35.5% of
driving samples had exactly one axis zeroed, crossing in and out 1.44 times a
second, and 10% of all large setpoint jumps traced to it. Now deadbanded as a
vector and rescaled from the band edge, so direction is exact and the output
ramps from zero. Rotation keeps a scalar deadband - one dimension has no
direction to distort - but is rescaled too.

SHIPPED - CustomDrivetrain.clampReversePower, same bug class in the Pedro
core. It capped each axis at +-0.2 independently against that axis's velocity,
so braking out of a forward-right drive clamped x to -0.2 while y kept -0.9 and
swung the commanded direction 30-45 degrees, releasing the instant that
component crossed zero. Now projects onto the direction of travel and scales
the WHOLE vector, preserving direction exactly. This one is in the SHIPPED path
only - SwerveBringUp calls arcadeDrive directly and bypasses it - so no archived
measurement contains it and DriveTeleOp may be worse than anything measured.

SHIPPED - Swerve.arcadeDrive, the two epsilon walls. Deleting the translation
or rotation term in one step rotates the demand by however far apart the two
directions were; on this nearly square chassis the rotation-only directions are
+-43.5/+-136.5 deg, which is why the symptom reads as snapping to 45. Now
tapered with a smoothstep across each band. Also a demand slew limit at 214
deg/s - the measured pod slew, so it costs nothing the hardware could have
delivered: a 90 deg change takes 420 ms of demand travel against the pod's own
measured 421 ms.

Simulated first over mydrive-001's recorded commands, 51.3 s of real driving
replayed through both mixers, physical (mod-180) consecutive-loop demand change:

                       p90      >15 deg/s    demand rev/s
  as shipped          19.8         2.9           4.25
  taper only          19.8         2.9           4.37
  slew 214 only       13.2         0.9           3.47
  taper + slew 214    13.2         0.9           3.47

Two honest results in there. The taper alone does almost nothing - epsilon
crossings are only 3% of jumps and the input usually crosses the wall in one
loop. And 300 deg/s, tried first, is WORSE than 214 at this loop rate: it
spreads one big jump into several 19 deg steps and the violation count rises.
53% of the jumps that survive the limiter happen on loops longer than 70 ms, so
criterion 1 is a loop-rate criterion as much as a mixer one - at 50 Hz true the
same limit permits 5.4 deg per loop.

DIAGNOSTIC - applyBoxLimit tapers the outward component over 6 in instead of
zeroing it at the threshold. The fence stays exactly as hard at the wall; the
approach stops being a step, and the margin's own speed dependence stops making
it chatter. dashboard.html gets the same vector deadband as DriveTeleOp.

DIAGNOSTIC - setMixer toggles the taper and the slew limit at runtime, and
pedroChain accepts a whole host-designed PathChain with every control point
bounds-checked before anything moves.

Tools: fixsim.py (replay a run through both mixers), pathdesign.py (Task 3
geometry). No robot time spent yet - all of the above is simulation over
recorded commands, and is labelled as such until it is measured.
Both are session artifacts, not project state. The files stay on disk; .gitignore
now keeps them out. CLAUDE.md and SWERVE_TASK.md stay tracked deliberately - the
first is checked-in project instruction, the second is the task this session runs.

Note for the operator: that same commit also carried your in-progress
diagnostics/motorbreakin.java, which was already staged when this session
started. Left as committed rather than rewriting history - shout if you want it
split back out.
CLAUDE.md carried six statements this session disproved or outdated. Corrected
in place, each with what replaced it:

  section 4  recorder columns were 7 global; they are 14 + 7 per pod, and the
             discriminator column to use is ctgt (from inside the pod), not tgt
             (a host-side mirror that has drifted). DriveTeleOp now publishes
             to the same /state and drives the same recorder.
  section 5  gains were four sessions stale - per-pod kP 0.380, kS 0.022,
             pulsed approach, gain scheduling. The lubrication pass it called
             'pending' had already happened and is why kS fell to 0.022. And
             'cache' is the servo-output caching threshold, not an encoder-read
             interval.
  section 6  the whole baseline table, re-measured. Two rows inverted: wheel
             path / COMMANDED path is 0.89-1.01 (not 1.7-3.0), and commanded
             reversals are 3.47-4.17/s (not 0.41-0.53).
  section 7  'atan2 with no magnitude gate is UNLIKELY' was wrong - it is 21%
             of the large jumps. 'The closed loop is hunting' is refuted
             outright. 'publish blocks on the Lynx bus' is refuted by a
             measurement taken with no actuator writes happening at all.

Baseline graphs for mydrive-001 committed alongside. The setpoint histogram is
the picture worth keeping: a ~20x spike at the 45 degree multiples against a
uniform baseline, which is the reported symptom, measured.
…ure it

SHIPPED. Task 1 asked why heading lock was bad. The answer is that there was
none. Traced end to end:

  DriveTeleOp.loop -> follower.setTeleOpDrive(f, s, turn, true)
    -> VectorCalculator: teleopHeadingVector = Vector(turn stick, heading)
    -> CustomDrivetrain.calculateDrive: headingPower.dot(Vector(1, heading))
       = the turn stick, unchanged
    -> Swerve.arcadeDrive(rotation = turn stick)

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. Every heading number this project has published
came from SwerveBringUp, which has a full hold with a phase machine, a lead cap
and trim - and is a diagnostic tool that does not ship.

HeadingHold carries that behaviour into shipped code: rate stick with a latched
setpoint, 60 deg lead cap (past 180 the error wraps and the turn reverses
mid-turn), a release sequence that waits for measured rotation to stop before
latching rather than rubber-banding against momentum, re-adoption of reality on
re-entry, and the stuck-sensor guard. It deliberately drops the epsilon-bypass
trim: that existed because the mixer deleted sub-epsilon rotation, and the
mixer now tapers instead, so the bypass would be a second hack over the same
deadband. Right bumper falls back to open loop - escape hatch and A/B in one.

NOT VALIDATED ON THE ROBOT. Gains are the bring-up tool's measured 1.20/0.080;
the structure is carried from an implementation that was validated, in an
OpMode that has never run it. Treat the first run as a test.

Known duplication, stated rather than hidden: SwerveBringUp keeps its own
inline copy. Rewriting the diagnostic tool's control path mid-session would
change what is being measured. The two must not drift - that exact failure
already cost this project once this session, when computeTargets mirrored the
mixer and the mixer moved.

Also from Task 1's evidence: heading error does NOT scale with translation
speed (r = -0.168; error is WORST at crawl, 12.2 deg mean under 5 in/s against
4.1 deg over 30). It scales with rotation command (r = +0.903). So this is not
module skew, wheelbase or odometry - the discriminator the task asked for
points at the controller, not the geometry. And the p95/max of exactly 60.00
deg is the lead cap saturating, not a controller property: no steady-state
heading number may be quoted from a sample taken while the turn stick is held.
…s it stands

Criterion 5 turns out to be already met (0.89-1.01x, not 1.7-3.0x - the old
figure divided wheel path by the wrong denominator). Criterion 1 is blocked on
Task 0 rather than on the mixer: a demand rate limit cannot beat a 77 ms p99
loop, because 15 deg at 214 deg/s IS 70 ms. Everything else needs robot time.
….8 ms

MEASURED, robot stationary in IDLE at 12.72 V, 6 randomised interleaved blocks,
n=114 samples per arm, identical 193-call payload in both arms:

    String.format   11.77 ms  sd 1.01  (9.50-15.10)   61.0 us per call
    hand-rolled      1.62 ms  sd 0.19  (1.11- 1.95)    8.4 us per call
    delta 10.15 ms, 95% CI [9.96, 10.34], t=105, df=121

The distributions do not overlap: the slowest fast sample is 1.95 ms, the
fastest String.format sample is 9.50. 209 numeric fields compared between the
two arms' snapshots, zero differences - a cost saving, not a rounding change.

That closes the question CLAUDE.md has carried as open since 2026-08-13. The
sequence of wrong answers is worth keeping: first it was blamed on
batteryVolts() inline in publish (partly true, 19.4 -> 30.9 Hz), then on eight
live getPower() transactions per publish (true, and fixed), then on the Lynx
bus blocking behind actuator writes - which this session refuted by measuring
36.7 ms with the robot at rest and no actuator writes at all. It was never the
bus. It was the SDK's number formatter, called a thousand times a publish.

Projection for the case that actually hurts, DRIVE with heading hold and the
260-sample trace full (~973 calls): 59.4 ms against 8.2 ms. That is the source
of the loop's bistability - publish runs off a 50 ms timer, so once it costs
more than 50 ms EVERY loop pays it and the slow mode sustains itself. Human
driving sat at 22-24 Hz true; the same code scripted sat at 51 Hz.

fastFmt now defaults on. setFastFmt still switches it at runtime, so the A/B
stays repeatable.

Tool: fmtab.py, randomised interleaved, no motion commanded at any point.
MEASURED on the robot, SwerveBringUp in DRIVE with a zero command throughout
(pods X-locked, chassis stationary), 12.71 V, interleaved A-B-B-A, real dt from
the recorder rather than any smoothed rate:

                   loop_hz_true   dt mean   p50    p90    p99    max   msPublish
  String.format         34.6      28.9 ms  39.0   52.2   60.1   68.3    35.16
  hand-rolled           95.6      10.5 ms   9.5   14.0   18.5   27.5     2.21

n = 431 + 418 and 1166 + 1171 samples. A 2.76x loop rate, and p90 dt falls from
52.2 ms to 14.0.

Two consequences beyond the number:

Criterion 1 unblocks. A demand slew limit is a RATE, so it only meets a
per-loop bound if the loop is fast: 15 deg at 214 deg/s IS 70 ms, and the p99
loop used to be 77. At a p99 of 18.5 ms the same limit permits 4.0 deg per
loop, comfortably inside the 15 deg criterion. The mixer fixes could not have
met criterion 1 on their own at the old loop rate, and simulation said so
before this measurement confirmed the loop half.

The bistability is gone. Publish runs off a 50 ms timer; at 35 ms per publish
almost every loop paid it, which is why the same code measured 22-24 Hz driven
by a human and 51 Hz driven by a script. At 2.2 ms it cannot dominate any loop.

Checked before trusting it: Pedro's PIDFController computes
errorDerivative = (error - previousError) / dt, so kD is rate-normalised and
tripling the loop rate does not triple the derivative gain. The pulsed approach
times itself off nanoTime and is rate-independent too. And the loop-rate-vs-
residual question does not need re-running - it was already refuted at 47.8 vs
92.1 Hz, which brackets where we now are.
Found by walking into it. Pedro's PinpointLocalizer constructor calls
setStartPose(new Pose()), which writes odo.setPosition(0, 0, 0) to the
HARDWARE - so merely starting any OpMode that builds a Follower re-origins the
Pinpoint wherever the robot is standing. The bring-up tool's saved safe-area
box lives in that frame. After the switch the box was still armed, still read
valid, and fenced a patch of floor 20 inches diagonally away from the real one.

The guard for this already existed and did not fire. boxNeedsFrameCheck
discarded a loaded box if the pose read (0, 0, 0) within 0.05 in and 0.1 deg -
which only catches a frame reset moments old. By the time bring-up came back
the heading had drifted to -0.63 deg and the box sailed through as valid.

Three fixes, in order of how much they matter:

SHIPPED - DriveTeleOp now reads the Pinpoint pose BEFORE building the follower
and hands it back with setStartingPose, so the frame stays continuous and the
trap stops existing. Autos set their own starting pose explicitly and are
unaffected.

DIAGNOSTIC - the frame check gets a tolerance wide enough to survive drift
(1.0 in, 2 deg) plus a frame WITNESS: saveBox now records where the robot stood
when the box was written, and a box that loads with the robot at the origin
while its witness says otherwise is discarded loudly. A witness mismatch that
is NOT the origin signature warns instead of discarding - the Pinpoint keeps
integrating with no OpMode running, so a pushed robot has a valid frame and a
moved pose. Verified on the robot: the stale box was discarded on restart.

DIAGNOSTIC - setPose and boxSet, the recovery pair. Re-marking corners means
driving to two walls with no fence armed, which is the more dangerous option,
not the safer one. Restoring a frame the robot never left is exact: the pose
came back to (14.5336, -13.8681, -1.308 deg) against a pre-reset
(14.5364, -13.8717, -1.36) - 0.003 in and 0.05 deg. boxSet says out loud that
it verified nothing.

No driving has happened. The box is armed again and awaiting a human check
against the mat.
MEASURED on tiles at 12.70-12.71 V, pods stepping only (zero drive power, the
chassis never moved).

Azimuth steady-state residual, 90 degree steps, two independent sessions:

    pods 0/2/3    0.56 - 0.75 deg mean      <- inside the 1.0 deg criterion
    pod 1         1.02 - 3.90 deg mean, excursions to 5.1-5.5

So criterion 3 is met on three quarters of the drivetrain and pod 1 is the
blocker. Against the 2.65-3.01 deg this file has carried since 2026-08-13 that
is a large improvement, but it is NOT attributable to anything in this session:
the gains changed on 2026-08-14/15 (per-pod kP 0.380, kS 0.022, pulsed
approach, gain scheduling) and this is the first time they have been scored
against criterion 3.

The cache = 0.01 hypothesis - that the servo write deadband latches the command
below ~1.5 deg of error and floors the residual - was tested directly.
Randomised interleaved, 0.010 vs 0.002, n=21 pod-runs per arm, pods 0/2/3:

    delta |ss| = +0.188 deg, 95% CI [-0.316, +0.692], t=0.73

Not significant. The point estimate favours the smaller deadband so it is not
ruled out at this n, but it is not the floor it was proposed as.

Also correcting something this file said for one commit. A first look at n=12
trials had pod 1's excursions landing 5/5 on the 90->0 direction and I wrote it
up as a repeatable one-directional fault. 56 further pod-runs did not support
that - the excursions appear in both directions and in both arms of the cache
A/B. Pod 1 is intermittent and unexplained, which is a weaker and more honest
claim than the one it replaces.

Rise 10-90% is 0.200-0.246 s against 0.37 s previously. Settle to +-2 deg reads
1.17-1.61 s, WORSE than the 647 ms on record, and the definition is the reason:
"stays inside the band for the rest of the record" makes it a measure of
post-settle pulse activity, not of the approach. Criterion 4 needs restating
before it can be judged.
@AXAStudio
AXAStudio merged commit cf52652 into master Aug 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant