Skip to content

Log per-module drive/turn voltage and current for sim identification - #10

Open
AdamEXu wants to merge 1 commit into
calgamesfrom
feat/log-swerve-motor-telemetry
Open

Log per-module drive/turn voltage and current for sim identification#10
AdamEXu wants to merge 1 commit into
calgamesfrom
feat/log-swerve-motor-telemetry

Conversation

@AdamEXu

@AdamEXu AdamEXu commented Aug 10, 2026

Copy link
Copy Markdown
Member

Why

We're calibrating a MuJoCo simulator of this robot (PWRDrive) so drive policies developed in sim transfer to the real thing. From the 2026-08-08 practice logs we could identify the closed-loop drive response — a 78 ms first-order lag with ~0 transport delay, and a speed-dependent acceleration droop of a = 7.34 − 1.36·v (m/s²).

What we can't identify is the open-loop model underneath: motor torque constant, effective gear/efficiency losses, and the traction limit. Those need applied voltage and current per module, and nothing in the current logs records them (Wheels/<port>/* has requested/current speed and angle only). Without them the sim has to assume the published Kraken X60 torque curve rather than measure ours, which is exactly the kind of assumption that shows up as sim-to-real drift under hard acceleration.

What this logs

Ten new Wheels/<port>/* AdvantageKit outputs per module, added to the existing logEverything(...) that drive() already calls:

Key Signal Why
driveAppliedVolts / turnAppliedVolts getMotorVoltage() the control input — pairs with measured wheel speed to fit the motor model
driveStatorAmps / turnStatorAmps getStatorCurrent() proportional to motor torque; this is what identifies kT and the traction limit
driveSupplyAmps / turnSupplyAmps getSupplyCurrent() battery-side load; needed for the voltage-sag part of the model
driveVoltsTimestamp / driveAmpsTimestamp / turnVoltsTimestamp / turnAmpsTimestamp getTimestamp().getTime() frame receive time, so the offline fit can align volts against amps — see below

No behaviour change: Logger.recordOutput calls only, all inside the existing logEverything. No call sites were added or moved.

Time alignment — the part that needs scrutiny

Verified in wpiapi-java-26.1.0-sources.jar (CoreTalonFX.java), the three signals do not share a status frame:

Getter SPN Frame CAN 2.0 default
getMotorVoltage PRO_MotorOutput_MotorVoltage MotorOutput 100 Hz
getStatorCurrent PRO_SupplyAndTemp_StatorCurrent SupplyAndTemp 4 Hz
getSupplyCurrent PRO_SupplyAndTemp_SupplyCurrent SupplyAndTemp 4 Hz

So voltage arrives on one frame at 100 Hz and current on another at 50 Hz, and a 50 Hz robot loop samples both with drifting phase. The (V, I) pair used to fit motor torque can therefore be misaligned by up to a frame — material against the 78 ms time constant we're trying to identify, not ignorable noise.

Bumping the rate doesn't fix this; the two frames are still independent. Instead:

  • the six signals are cached once in the constructor (via the getX(false) overloads) and refreshed together with BaseStatusSignal.refreshAll(...) once per logEverything call, so every logged value comes from one coherent read;
  • each frame's timestamp is logged — *VoltsTimestamp and *AmpsTimestamp — so the offline fit can resample onto a common time base instead of assuming the pair was sampled together.

Two timestamps per motor is the minimum that's actually correct: stator and supply share a frame (one timestamp covers both), voltage does not. getTimestamp() returns the most accurate source available; on the roboRIO bus that's System (receive time), which is what we need for relative alignment.

CAN bus load

Measured from the same logs, SystemStats/CANBus/Utilization on this robot is already mean 0.69, max 1.00, so this needed a real number rather than a guess.

getMotorVoltage() is free. MotorOutput already ships at 100 Hz by default on CAN 2.0 — those frames are on the bus whether or not we read them. Adding the 8 voltage signals costs zero extra bandwidth, and the rate is left alone.

Only SupplyAndTemp is raised, 4 Hz → 50 Hz. Because stator and supply share that frame, setUpdateFrequencyForAll is called with only the stator signal per motor — passing supply as well would be a no-op (highest frequency wins for a shared frame). There's a comment in the code saying so, since the omission looks like a bug otherwise.

Arithmetic, with assumptions stated so you can check it:

  • Bus is roboRIO CAN 2.0 @ 1 Mbit/s (new TalonFX(id) with no bus name → "rio"; a CANivore would make this moot).
  • One CTRE status frame = 29-bit extended ID + 8 data bytes ≈ 131 bits raw, ≈ 145 bits with typical bit stuffing → ~0.0145% of the bus per Hz.
  • 8 motors (4 drive + 4 turn), one SupplyAndTemp frame each, 4 Hz → 50 Hz = +46 Hz+0.67% of the bus per frame.
  • 8 frames × 0.67% ≈ +5.4%.

+5.4 percentage points while enabled — definitive, not a range, because the shared-frame question is settled by the SPN table above. On a bus already averaging 69% that's still not something to leave on by accident, hence:

The switch

// SwerveConstants.java
public static final boolean kLogMotorTelemetry = false;   // ← default
public static final double  kMotorTelemetryHz  = 50.0;

Default is false. With the flag off: no signals are cached, no update frequency is changed, no extra frames hit the bus, and no Logger.recordOutput calls execute — the robot behaves exactly as it does today. This is now literally true; nothing in this PR runs unconditionally.

Because kLogMotorTelemetry is a compile-time constant, javac eliminates the dead branches outright. Disassembling the built class with the flag off:

javap -c -p build/classes/java/main/frc/robot/hardware/WheelMoverTalonFX.class
# references to getStatorCurrent | getSupplyCurrent | setUpdateFrequencyForAll
#              | refreshAll | getMotorVoltage | getTimestamp : 0
# new log-key strings in the constant pool                   : 0

The only trace left is six unused private fields. (This is also why the constants stay static final rather than becoming per-variant instance fields on INSTANCE — instance fields would defeat the folding and make the flag a real runtime cost on a saturated bus.) They sit with the other file-level statics at the top of SwerveConstants, alongside kRobotMaxSpeed / kShootingSpeedMultiplier.

kMotorTelemetryHz is exposed so the rate can be dropped (25 Hz still resolves a 78 ms time constant fine) if bus headroom turns out tighter than the estimate.

How to verify

Compilation: clean. This branch is based on comp-sfde, which compiles with zero errors. This branch also compiles with zero errors and the same 8 pre-existing deprecation warnings (all in WheelMoverSpark, untouched here):

./gradlew compileJava -x buildDynamicDeps    # BUILD SUCCESSFUL, 8 warnings, 0 errors

(-x buildDynamicDeps only because that task rebuilds the vendored autobahn_client from source and 6 of its tests fail on comp-sfde already — unrelated, and it fails identically on the clean base.)

The two changed files were also compiled directly against the real Gradle compileClasspath (Phoenix 6 26.1.0, AdvantageKit, WPILib 2026) as a second check, and produced clean .class output.

On the robot:

  1. Set kLogMotorTelemetry = true, deploy, drive.
  2. Watch SystemStats/CANBus/Utilization — expect roughly +5 points. Materially more than that means the frame arithmetic above is wrong and kMotorTelemetryHz should drop to 25.
  3. Confirm Wheels/<port>/driveStatorAmps updates at ~50 Hz rather than stepping at 4 Hz, which proves the frequency call took effect.
  4. Sanity-check that driveVoltsTimestamp and driveAmpsTimestamp differ per sample — if they're identical every loop, the timestamps aren't carrying frame-level information and the alignment story needs rechecking.
  5. Set the flag back to false before any event.

🤖 Generated with Claude Code


Supersedes #8, which GitHub auto-closed when its branch was renamed from claude/ to feat/. Same commit, same review.

The 2026-08-08 practice logs pin down the drivetrain's closed-loop response
(78 ms first-order lag, ~0 transport delay, a = 7.34 - 1.36*v) but not the
motor or traction model underneath it, because applied voltage and current
are never recorded. Without them a simulator has to assume the published
Kraken torque curve instead of measuring ours.

Adds driveAppliedVolts/StatorAmps/SupplyAmps and the turn equivalents to
WheelMoverTalonFX.logEverything, which drive() already calls.

Motor voltage and the current signals live on different TalonFX status
frames (MotorOutput vs SupplyAndTemp) published at different rates, so a
50 Hz loop reads each pair up to a frame apart. The signals are cached and
refreshed together via BaseStatusSignal.refreshAll, and each frame's
timestamp is logged so the offline fit can time-align volts against amps
rather than assuming they were sampled together.

Raising SupplyAndTemp from 4 Hz to 50 Hz costs bus bandwidth on a bus that
already averages 69% and peaks at 100% utilization, so the whole thing is
gated behind SwerveConstants.kLogMotorTelemetry, default false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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