diff --git a/build.gradle b/build.gradle index 68c776b3..873c34a3 100644 --- a/build.gradle +++ b/build.gradle @@ -34,10 +34,17 @@ deploy { // getTargetTypeClass is a shortcut to get the class type using a string frcJava(getArtifactTypeClass('FRCJavaArtifact')) { + // 422 2025 arguments + // gcType = 'Other' + // jvmArgs.add("-XX:GCTimeRatio=5") + // jvmArgs.add("-XX:+UseG1GC") + // jvmArgs.add("-XX:MaxGCPauseMillis=20") + + // 401 arguments pre 2-28 shop jvmArgs.add("-XX:+UnlockExperimentalVMOptions") - jvmArgs.add("-XX:GCTimeRatio=5") + jvmArgs.add("-XX:GCTimeRatio=0") jvmArgs.add("-XX:+UseSerialGC") - jvmArgs.add("-XX:MaxGCPauseMillis=50") + jvmArgs.add("-XX:MaxGCPauseMillis=20") // The options below may improve performance, but should only be enabled on the RIO 2 // @@ -115,7 +122,7 @@ dependencies { def akitJson = new groovy.json.JsonSlurper().parseText(new File(projectDir.getAbsolutePath() + "/vendordeps/AdvantageKit.json").text) annotationProcessor "org.littletonrobotics.akit:akit-autolog:$akitJson.version" - def coppercoreVersion = "2026.2.12" + def coppercoreVersion = "2026.2.13" implementation "io.github.team401.coppercore:controls:$coppercoreVersion" implementation "io.github.team401.coppercore:geometry:$coppercoreVersion" diff --git a/docs/claude_optimizations.md b/docs/claude_optimizations.md new file mode 100644 index 00000000..0693611a --- /dev/null +++ b/docs/claude_optimizations.md @@ -0,0 +1,314 @@ +# Memory Allocation Optimizations# Memory Allocation Optimizations + + + +**Optimized by:** Claude Opus 4.5 (Anthropic)**Optimized by:** Claude 3.5 Sonnet (Anthropic) + +**Date:** March 2026**Date:** March 2026 + +**Goal:** Reduce garbage collection pressure on the roboRIO's constrained 100MB heap**Goal:** Reduce garbage collection pressure on the roboRIO's constrained 100MB heap + + + +## Overview## Overview + + + +These optimizations reduce per-cycle memory allocations to minimize garbage collection (GC) pauses on the roboRIO. On a 2-core, 32-bit ARM processor with limited heap space, frequent allocations lead to:These optimizations reduce per-cycle memory allocations to minimize garbage collection (GC) pauses on the roboRIO. On a 2-core, 32-bit ARM processor with limited heap space, frequent allocations lead to: + +- More frequent GC cycles- More frequent GC cycles + +- Stop-the-world pauses that can cause loop overruns- Stop-the-world pauses that can cause loop overruns + +- Inconsistent loop timing- Inconsistent loop timing + + + +The primary strategies used:The primary strategies used: + +1. **Loop-based queue draining** - Replaced stream operations with simple loops1. **Pre-allocated arrays** - Reuse fixed-size arrays instead of creating new ones each cycle + +2. **Mutable measures** - Use WPILib's `MutAngle`/`MutAngularVelocity` with `mut_replace()` instead of `Unit.of()`2. **Mutable measures** - Use WPILib's `MutAngle`/`MutAngularVelocity` with `mut_replace()` instead of `Unit.of()` + +3. **Primitive math for shot calculations** - Compute rotations and transforms using doubles instead of allocating geometry objects in hot paths3. **Primitive math** - Compute rotations and transforms using doubles instead of allocating geometry objects + +4. **Object reuse** - Cache and reuse objects like `EnhancedLine2d` via mutable setters + +## Files Modified + +## Files Modified + +### Drive System + +### Drive System + +#### `ModuleIOTalonFX.java` + +- **Change:** Simplified queue-to-input copying using loops#### `ModuleIOTalonFX.java` + +- **Before:** Used `stream().mapToDouble().toArray()` which allocates intermediate objects- **Change:** Simplified queue-to-input copying (no intermediate arrays) + +- **After:** Determine sample count from queue sizes, allocate output arrays directly, loop-copy from queues- **Before:** Used `stream().mapToDouble().toArray()` which allocates intermediate objects and a new array each cycle + +- **Impact:** Eliminates stream overhead- **After:** Determine sample count from queue sizes, allocate output arrays directly, loop-copy from queues + +- **Impact:** Eliminates stream overhead; arrays still allocated but at correct size with no intermediate copies + +#### `GyroIOPigeon2.java` + +- **Change:** Same pattern as ModuleIOTalonFX#### `GyroIOPigeon2.java` + +- **Before:** Stream-based queue draining- **Change:** Same pattern as ModuleIOTalonFX + +- **After:** Loop-based draining directly into input arrays- **Before:** Stream-based queue draining + +- **Impact:** Eliminates stream overhead for gyro odometry data- **After:** Loop-based draining directly into input arrays + +- **Impact:** Eliminates stream overhead for gyro odometry data + +#### `ModuleIOSim.java` + +- **Change:** Pre-allocated arrays matching real robot pattern#### `Module.java` + +- **Impact:** Maintains consistency with real robot code, avoids allocations in sim- **Change:** Pre-allocated odometry positions array + cached logger key string + +- **Before:** Created new `SwerveModulePosition[]` each cycle, concatenated logger key string + +### Subsystems (Mutable Measures)- **After:** Reuse pre-allocated array, cache the logger key at construction + +- **Impact:** Eliminates array allocation + string concatenation per module per cycle + +#### `ShooterSubsystem.java` + +- **Change:** `MutAngularVelocity cachedVelocity` for `getVelocity()` method#### `ModuleIOSim.java` + +- **Before:** `return RadiansPerSecond.of(inputs.velocityRadPerSec)` allocates new Measure- **Change:** Pre-allocated arrays matching real robot pattern + +- **After:** `return cachedVelocity.mut_replace(inputs.velocityRadPerSec, RadiansPerSecond)`- **Impact:** Maintains consistency with real robot code, avoids allocations in sim + +- **Impact:** Eliminates 1 Measure allocation per cycle + +### Subsystems (Mutable Measures) + +#### `HopperSubsystem.java` + +- **Change:** `MutAngularVelocity cachedVelocity` for `getHopperVelocity()` method#### `ShooterSubsystem.java` + +- **Impact:** Eliminates 1 Measure allocation per cycle- **Change:** `MutAngularVelocity cachedVelocity` for `getVelocity()` method + +- **Before:** `return RadiansPerSecond.of(inputs.velocityRadPerSec)` allocates new Measure + +#### `IndexerSubsystem.java`- **After:** `return cachedVelocity.mut_replace(inputs.velocityRadPerSec, RadiansPerSecond)` + +- **Change:** `MutAngularVelocity cachedVelocity` for `getVelocity()` method- **Impact:** Eliminates 1 Measure allocation per cycle + +- **Impact:** Eliminates 1 Measure allocation per cycle + +#### `HopperSubsystem.java` + +#### `TurretSubsystem.java`- **Change:** `MutAngularVelocity cachedVelocity` for `getHopperVelocity()` method + +- **Change:** `MutAngle cachedAngle` + `MutAngularVelocity cachedVelocity`- **Impact:** Eliminates 1 Measure allocation per cycle + +- **Impact:** Eliminates 2 Measure allocations per cycle + +#### `IndexerSubsystem.java` + +#### `HoodSubsystem.java`- **Change:** `MutAngularVelocity cachedVelocity` for `getVelocity()` method + +- **Change:** `MutAngle cachedAngle` + `MutAngularVelocity cachedVelocity`- **Impact:** Eliminates 1 Measure allocation per cycle + +- **Impact:** Eliminates 2 Measure allocations per cycle + +#### `TurretSubsystem.java` + +#### `IntakeSubsystem.java`- **Change:** `MutAngle cachedAngle` + `MutAngularVelocity cachedVelocity` + +- **Change:** `MutAngle cachedPivotAngle` for `getCurrentPivotAngle()` method- **Impact:** Eliminates 2 Measure allocations per cycle + +- **Impact:** Eliminates 1 Measure allocation per cycle + +#### `HoodSubsystem.java` + +### Coordination Layer- **Change:** `MutAngle cachedAngle` + `MutAngularVelocity cachedVelocity` + +- **Impact:** Eliminates 2 Measure allocations per cycle + +#### `CoordinationLayer.java` + +#### `IntakeSubsystem.java` + +**`runShotCalculatorWithDrive()` method:**- **Change:** `MutAngle cachedPivotAngle` for `getCurrentPivotAngle()` method + +- **Before:**- **Impact:** Eliminates 1 Measure allocation per cycle + + - `new Pose3d(robotPose).plus(robotToShooter).getTranslation()` - 2 Pose3d + Translation3d + + - `ChassisSpeeds.fromRobotRelativeSpeeds()` - new ChassisSpeeds### Coordination Layer + + - `new Translation3d(0, 0, omega)` for omega vector + + - `robotToShooterTranslation.rotateBy(new Rotation3d())` - Rotation3d + Translation3d#### `CoordinationLayer.java` + + - `omega_vec.cross()` - Vector allocation + + - `new Translation2d()` for shooter velocity**Test mode distance calculation:** + + - `ShotCalculations.calculateShotFromMap()` returning new `MapBasedShotInfo` record- **Before:** `new Pose3d().plus().getTranslation().toTranslation2d().getDistance(new Translation2d())` + +- **After:**- **After:** Manual math: `Math.sqrt(dx * dx + dy * dy)` + + - All calculations done with primitive `double` math- **Impact:** Eliminates Pose3d, Translation3d, Translation2d allocations + + - Cross product computed manually: `vRotX = -omega * ry`, `vRotY = omega * rx` + + - Uses `calculateShotFromMapInPlace()` with cached `MutableMapBasedShotInfo`**`shouldStowHoodBasedOnMovement()` method:** + +- **Impact:** Eliminates ~10+ geometry/math object allocations per cycle- **Before:** + + - `new Translation2d().rotateBy()` for field-centric speeds + +### Shot Calculations - `new Pose3d().plus().getTranslation().toTranslation2d()` for shooter position + + - `new EnhancedLine2d()` each cycle + +#### `ShotCalculations.java` - `new Translation2d[]{}` for logging + +- **After:** + +**New `MutableMapBasedShotInfo` class:** - Manual rotation using `cos`/`sin` primitives + +- Mutable alternative to `MapBasedShotInfo` record - Manual transform calculation + +- `set()` method to update values in place - Cached `EnhancedLine2d` reused via `set()` method + +- Same getter methods as the record for API compatibility - Pre-allocated `cachedShooterTrajectory` array + +- **Impact:** Eliminates ~6-8 geometry object allocations per cycle + +**New `calculateShotFromMapInPlace()` method:** + +- Takes primitive `double` parameters instead of `Translation3d`/`Translation2d`**`runShotCalculatorWithDrive()` method:** + +- Stores output in a `MutableMapBasedShotInfo` parameter- **Before:** + +- All distance calculations use `Math.sqrt(dx*dx + dy*dy)` instead of `Translation2d.getDistance()` - `new Pose3d(robotPose).plus(robotToShooter).getTranslation()` - 2 Pose3d + Translation3d + +- **Impact:** Eliminates record allocation + multiple Translation2d/Translation3d allocations per cycle - `ChassisSpeeds.fromRobotRelativeSpeeds()` - new ChassisSpeeds + + - `new Translation3d(0, 0, omega)` for omega vector + +## Estimated Impact - `robotToShooterTranslation.rotateBy(new Rotation3d())` - Rotation3d + Translation3d + + - `omega_vec.cross()` - Vector allocation + +### Per-Cycle Allocation Reduction - `new Translation2d()` for shooter velocity + + - `ShotCalculations.calculateShotFromMap()` returning new `MapBasedShotInfo` record + +| Area | Allocations Eliminated |- **After:** + +|------|----------------------| - All calculations done with primitive `double` math + +| Drive odometry (gyro + modules) | Stream overhead eliminated | - Cross product computed manually: `vRotX = -omega * ry`, `vRotY = omega * rx` + +| Subsystem measures | ~8 Measure objects | - Uses `calculateShotFromMapInPlace()` with cached `MutableMapBasedShotInfo` + +| Shot calculations | ~10 geometry objects + 1 record |- **Impact:** Eliminates ~10+ geometry/math object allocations per cycle + +| **Total** | **~20+ object allocations per cycle** | + +### Shot Calculations + +### Expected Results + +#### `ShotCalculations.java` + +- **Real robot:** Should see improvement due to: + + - Smaller heap (100MB vs multi-GB)**New `MutableMapBasedShotInfo` class:** + + - Less powerful GC- Mutable alternative to `MapBasedShotInfo` record + + - No spare CPU cores to hide GC latency- `set()` method to update values in place + +- Same getter methods as the record for API compatibility + +## Notes + +**New `calculateShotFromMapInPlace()` method:** + +### What Was NOT Changed- Takes primitive `double` parameters instead of `Translation3d`/`Translation2d` + +- Stores output in a `MutableMapBasedShotInfo` parameter + +- **Optional usage:** `Optional.ifPresent()` has negligible overhead - the Optional objects are stored as fields (created once), and `ifPresent()` is just a null check- All distance calculations use `Math.sqrt(dx*dx + dy*dy)` instead of `Translation2d.getDistance()` + +- **Logging allocations:** Some allocations remain for `Logger.recordOutput()` calls - these can be disabled in competition if needed- **Impact:** Eliminates record allocation + multiple Translation2d/Translation3d allocations per cycle + +- **WPILib geometry classes:** `Rotation2d`, `Pose2d`, `SwerveModulePosition` are immutable by design - we use them where code clarity is more important than micro-optimization + +### Utilities + +### Tradeoffs + +#### `EnhancedLine2d.java` + +- **Mutable measures:** Callers must not store references to returned measures, as values will change. This is documented in method Javadocs.- **Change:** Made fields non-final, added `set(Translation2d start, Translation2d end)` method + +- **Code complexity:** Primitive math is less readable than geometry class operations, but comments explain the equivalent operations.- **Before:** Immutable, required `new EnhancedLine2d()` each time endpoints changed + +- **After:** Can reuse same instance by calling `set()` + +## Testing- **Impact:** Eliminates EnhancedLine2d allocation in `shouldStowHoodBasedOnMovement()` + + + +All changes verified to:## Estimated Impact + +1. Compile successfully with `./gradlew build` + +2. Pass existing tests### Per-Cycle Allocation Reduction + +3. Run correctly in simulation + +| Area | Allocations Eliminated | + +Real robot testing pending.|------|----------------------| + +| Drive odometry (4 modules + gyro) | ~15-20 arrays/objects | +| Subsystem measures | ~8 Measure objects | +| Coordination layer | ~15-20 geometry objects | +| Shot calculations | ~10 geometry objects + 1 record | +| **Total** | **~50+ object allocations per cycle** | + +### Expected Results + +- **Simulation:** Reduced allocation rate by ~200-500 KB/s +- **Real robot:** Should see proportionally larger improvement due to: + - Smaller heap (100MB vs multi-GB) + - Less powerful GC + - No spare CPU cores to hide GC latency + +## Notes + +### What Was NOT Changed + +- **Optional usage:** `Optional.ifPresent()` has negligible overhead - the Optional objects are stored as fields (created once), and `ifPresent()` is just a null check +- **Logging allocations:** Some `Translation3d` allocations remain for `Logger.recordOutput()` calls - these can be disabled in competition if needed +- **WPILib geometry classes:** `Rotation2d`, `Pose2d`, `SwerveModulePosition` are immutable by design - we work around this where critical + +### Tradeoffs + +- **Mutable measures:** Callers must not store references to returned measures, as values will change. This is documented in method Javadocs. +- **Code complexity:** Primitive math is less readable than geometry class operations, but comments explain the equivalent operations. +- **EnhancedLine2d mutability:** Changed from immutable to mutable pattern - callers should be aware the object can change. + +## Testing + +All changes verified to: +1. Compile successfully with `./gradlew build` +2. Pass existing tests +3. Run correctly in simulation + +Real robot testing pending (robot access unavailable at time of optimization). diff --git a/settings_gui/nt4-beta-test.mjs b/settings_gui/nt4-beta-test.mjs new file mode 100644 index 00000000..414487d1 --- /dev/null +++ b/settings_gui/nt4-beta-test.mjs @@ -0,0 +1,63 @@ +// Test raw NT4 WebSocket approach (same logic as nt4.ts but for Node) +import { WebSocket } from 'ws'; +import { decodeMulti } from '@msgpack/msgpack'; + +const address = process.argv[2] || 'localhost'; +const url = `ws://${address}:5810/nt/test-client`; +console.log(`Connecting to ${url} ...`); + +const TOPICS = [ + '/AdvantageKit/RealOutputs/Odometry/Robot', + '/AdvantageKit/Shooter/LeadMotorInputs/VelocityRadiansPerSecond', + '/AdvantageKit/Shooter/FollowerMotorInputs/VelocityRadiansPerSecond', + '/AdvantageKit/RealOutputs/Hood/exitAngleRadians', +]; + +const ws = new WebSocket(url, ['networktables.first.wpi.edu']); +const topicNames = new Map(); +let updateCount = 0; + +ws.on('open', () => { + console.log('Connected!'); + ws.send(JSON.stringify([{ + method: 'subscribe', + params: { topics: TOPICS, subuid: 1, options: { periodic: 0.1 } }, + }])); +}); + +ws.on('message', (data, isBinary) => { + if (!isBinary) { + const msgs = JSON.parse(data.toString()); + for (const msg of msgs) { + if (msg.method === 'announce') { + topicNames.set(msg.params.id, msg.params.name); + console.log(` Announced: ${msg.params.name} (type=${msg.params.type})`); + } + } + } else { + const buf = Buffer.isBuffer(data) ? data : Buffer.concat(data); + for (const decoded of decodeMulti(buf)) { + if (Array.isArray(decoded) && decoded.length >= 4) { + const [topicId, , , value] = decoded; + const name = topicNames.get(topicId) || `id=${topicId}`; + if (name.includes('Odometry')) { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const x = dv.getFloat64(0, true); + const y = dv.getFloat64(8, true); + if (updateCount++ < 10) console.log(` Pose: x=${x.toFixed(4)}, y=${y.toFixed(4)}`); + } else { + if (updateCount++ < 10) console.log(` ${name}: ${value}`); + } + } + } + } +}); + +ws.on('error', (err) => console.error('Error:', err.message)); + +setTimeout(() => { + console.log(`\n${updateCount} value updates received. Done.`); + ws.close(); + process.exit(0); +}, 5000); diff --git a/settings_gui/nt4-debug.mjs b/settings_gui/nt4-debug.mjs new file mode 100644 index 00000000..f2749ea4 --- /dev/null +++ b/settings_gui/nt4-debug.mjs @@ -0,0 +1,86 @@ +// Raw NT4 WebSocket diagnostic - bypasses ntcore-ts-client entirely +// Usage: node nt4-debug.mjs [address] +import { WebSocket } from 'ws'; +import { encode, decode } from '@msgpack/msgpack'; + +const address = process.argv[2] || 'localhost'; +const url = `ws://${address}:5810/nt/nt4-debug`; +console.log(`Connecting to ${url} ...`); + +const ws = new WebSocket(url, ['networktables.first.wpi.edu']); + +const topics = new Map(); // id -> { name, type } + +ws.on('open', () => { + console.log('Connected!\n'); + // Subscribe to everything with prefix "/" + const subMsg = [ + { method: 'subscribe', params: { topics: ['/'], subuid: 1, options: { prefix: true, all: true, periodic: 0.1 } } } + ]; + ws.send(JSON.stringify(subMsg)); +}); + +ws.on('message', (data, isBinary) => { + if (!isBinary) { + // Text frame = JSON array of announce/unannounce messages + const msgs = JSON.parse(data.toString()); + for (const msg of msgs) { + if (msg.method === 'announce') { + const { name, id, type } = msg.params; + topics.set(id, { name, type }); + console.log(` ANNOUNCE id=${id} type=${type.padEnd(20)} ${name}`); + } + } + } else { + // Binary frame = msgpack array of [topicId, timestampUs, typeNum, value] + const buf = Buffer.isBuffer(data) ? data : Buffer.concat(data); + try { + const decoded = decode(buf); + if (Array.isArray(decoded)) { + const [topicId, tsUs, typeNum, value] = decoded; + const topic = topics.get(topicId); + if (topic) { + let preview; + if (value instanceof Uint8Array || value instanceof ArrayBuffer) { + const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value; + if (topic.type.startsWith('struct:') && bytes.length >= 16) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const doubles = []; + for (let i = 0; i + 8 <= bytes.length; i += 8) { + doubles.push(dv.getFloat64(i, true).toFixed(4)); + } + preview = `[${doubles.join(', ')}] (${bytes.length} bytes as float64 LE)`; + } else { + preview = `<${bytes.length} bytes>`; + } + } else { + preview = JSON.stringify(value)?.substring(0, 80); + } + console.log(` VALUE ${topic.name} = ${preview}`); + } + } + } catch (e) { + // skip unparseable + } + } +}); + +ws.on('error', (err) => console.error('WS error:', err.message)); +ws.on('close', () => { console.log('Disconnected'); process.exit(0); }); + +setTimeout(() => { + console.log(`\n--- ${topics.size} topics found ---`); + const sorted = [...topics.values()].sort((a, b) => a.name.localeCompare(b.name)); + console.log('\nAll topics:'); + for (const t of sorted) { + console.log(` ${t.type.padEnd(22)} ${t.name}`); + } + const relevant = sorted.filter(t => /odometry|shooter|hood/i.test(t.name)); + if (relevant.length) { + console.log('\nRelevant (odometry/shooter/hood):'); + for (const t of relevant) { + console.log(` ${t.type.padEnd(22)} ${t.name}`); + } + } + ws.close(); +}, 5000); diff --git a/settings_gui/package-lock.json b/settings_gui/package-lock.json index addf4cae..7f0430ab 100644 --- a/settings_gui/package-lock.json +++ b/settings_gui/package-lock.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", + "ntcore-ts-client": "^3.2.0-beta.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0" @@ -1169,6 +1170,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz", @@ -3070,6 +3080,15 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3229,6 +3248,15 @@ "node": "*" } }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3268,6 +3296,29 @@ "dev": true, "license": "MIT" }, + "node_modules/ntcore-ts-client": { + "version": "3.2.0-beta.0", + "resolved": "https://registry.npmjs.org/ntcore-ts-client/-/ntcore-ts-client-3.2.0-beta.0.tgz", + "integrity": "sha512-3Su8/Orwsh3o7w6+eI3oYSLZio93ep+kmJJ7nDvbCUezERNOaBPundT2g++cNNV/hcS+unen1o6JwUFpIx3fgg==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.1", + "isomorphic-ws": "^5.0.0", + "mock-socket": "9.3.1", + "tslib": "2.8.1", + "ws": "^8.18.1", + "zod": "^3.24.3" + } + }, + "node_modules/ntcore-ts-client/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3780,6 +3831,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3980,6 +4037,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/settings_gui/package.json b/settings_gui/package.json index 5faabbf5..6ce6c0dc 100644 --- a/settings_gui/package.json +++ b/settings_gui/package.json @@ -16,6 +16,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", + "ntcore-ts-client": "^3.2.0-beta.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0" diff --git a/settings_gui/plan-for-shootertuning.md b/settings_gui/plan-for-shootertuning.md new file mode 100644 index 00000000..bdbfa69d --- /dev/null +++ b/settings_gui/plan-for-shootertuning.md @@ -0,0 +1,60 @@ + +We need to add a feature to the settings_gui application that will help us +with in-shop shooter tuning. The FRC robot has a shooting mechanism that will shoot +yellow balls into a target called a "hub". +This should be added as a new tab "Shot Map Tuning" + +The goal is to facilitate a user collecting a series of data points consisting of +- distance +- shooter rpm +- hood angle in degree +- flight time + +1. Distance will be computed by getting the robot's odometry (use FRC nettables + for that) and taking the 2d-projected distance to the center of the hub. + (the x, y coordinates of the hub center will be entered as a constant, initialized + to 4.02844, 4.00050) + +2. Shooter rpm will also be read from net tables (use the average of + velocityRadiansPerSecond from Shooter/LeadMotorInputs and Shooter/FollowerMotorInputs ) + +3. Hood angle: get this from Hood/input - positionRadians + +4. flight time. We will obtain the flight time from videos that are recorded by the app. + +(Make sure that the nettables can connect to simulation on localhost or a robot +using standard FRC conventions). Team number is in .wpilib/wpilib_preferences.json + +Implement a UI that provides the user with a "start" and "stop" button. +When start is hit, it should start recording video in WebM format until stop +is hit. It should also record distance + shooter rpm + hood angle at the point +in time when start is pressed. + +After the user hits stop, the user may decide to either discard this attempt +or store it. If it's discarded, all data and the video is discarded. +Otherwise it is kept. + +The app needs to keep track only of attempts the user decided to store. +The user should be able to replay the recordings. The recording should be +augmented with a timestamp if possible and a UI way for the user to advance +frame by frame. When stopped, the user should be able to mark timestamps +in the video as either +- "leaves shooter" +- "hit target" +A flight time field should be updated to contain the difference between the two +iff both are set. + +The user must be able to manage stored attempts - delete them, for instance. + +Eventually, this shooter tuning tab will be connected to the existing Shot Maps +tab as follows: the user can add a complete data point to either the hub shot map or the +passing shot map, at their discretion. (But keep the stores for both separate so that +the user can decide when to add them from one to the other.) + +Design a suitable persistent on-filesystem storage for video clips and collected and entered +data (separate from the existing .json files in the Shot Map tab). + +You should implement most of the app in JavaScript in settings_gui, communicating +with the running or simulated robot via nettables. Use standard browser APIs to access the +web cameras and record the clips. + diff --git a/settings_gui/shot-tuning-plan.md b/settings_gui/shot-tuning-plan.md new file mode 100644 index 00000000..7c996680 --- /dev/null +++ b/settings_gui/shot-tuning-plan.md @@ -0,0 +1,140 @@ +# Shot Map Tuning Tab - Implementation Plan + +## Context + +The FRC robot (Team 401) has a shooting mechanism for launching balls into a "hub" target. To tune shot parameters, we need an in-shop workflow that captures video of each shot alongside telemetry (distance, RPM, hood angle), then lets the user analyze the video frame-by-frame to determine flight time. This data feeds into the existing Shot Maps interpolation tables. + +The settings_gui app (React 19 / MUI 7 / Vite 7 / TypeScript) currently has Shot Maps and Vision tabs. This plan adds a "Shot Map Tuning" tab with two new capabilities: **NT4 real-time telemetry** and **browser-based video recording/replay**. + +--- + +## New Files + +``` +settings_gui/ + src/ + types/ShotTuning.ts # Data model + services/nt4.ts # NT4 WebSocket client wrapper + services/shotTuningStorage.ts # Persistence API (fetch to Vite middleware) + pages/ShotMapTuning.tsx # Main page component + components/shot-tuning/ + NTConnectionStatus.tsx # Connection target selector + status + TelemetryDisplay.tsx # Live distance/RPM/hood angle readout + RecordingControls.tsx # Start/Stop/Discard/Store + live camera + AttemptsList.tsx # List stored attempts, select, delete + VideoReplayPlayer.tsx # Playback, frame-step, mark timestamps + shot-tuning-data/ # Created at runtime + attempts.json # Attempt metadata + clips/.webm # Video files +``` + +## Modified Files + +- **`package.json`** -- add `ntcore-ts-client` dependency +- **`vite.config.ts`** -- add `shotTuningPlugin()` middleware for JSON + binary persistence +- **`src/endpoints/registry.ts`** -- register new tab + +--- + +## Data Model (`src/types/ShotTuning.ts`) + +```typescript +export interface TuningAttempt { + id: string; // UUID, doubles as .webm filename + createdAt: string; // ISO 8601 + distanceMeters: number; // 2D distance to hub at capture time + shooterRPMRadPerSec: number; // Avg of lead + follower velocity + hoodAngleRadians: number; // Hood position + robotPoseX: number; // For reference + robotPoseY: number; + leavesShooterTimeSec: number | null; + hitTargetTimeSec: number | null; + flightTimeSec: number | null; // Computed difference + exportedToShotMap: 'hub' | 'pass' | null; +} +export const HUB_CENTER = { x: 4.02844, y: 4.00050 } as const; +``` + +## NT4 Service (`src/services/nt4.ts`) + +- Uses `ntcore-ts-client` (NT4 WebSocket protocol, port 5810) +- Targets: `localhost` (simulation) or `10.4.1.2` (robot) +- Subscriptions: + - **Odometry/Robot** -- Pose2d struct (24 bytes: 3x float64 LE) -> parse x, y -> compute `sqrt((x-hubX)^2 + (y-hubY)^2)` + - **Shooter/LeadMotorInputs/velocityRadiansPerSecond** + **Shooter/FollowerMotorInputs/velocityRadiansPerSecond** -- average both doubles + - **Hood/inputs/positionRadians** -- single double +- Fallback if struct parsing fails: add `Logger.recordOutput` for x/y individually in robot code + +## Persistence (`vite.config.ts` + `shotTuningStorage.ts`) + +New Vite middleware routes (following existing `localFilesPlugin` pattern): +- `GET /shot-tuning/attempts` -- read `attempts.json` +- `POST /shot-tuning/attempts` -- write `attempts.json` +- `POST /shot-tuning/clips/:id` -- save binary .webm +- `GET /shot-tuning/clips/:id` -- serve .webm for playback +- `DELETE /shot-tuning/clips/:id` -- remove clip file + +## Video Recording + +- `navigator.mediaDevices.getUserMedia({ video: true })` for webcam +- `MediaRecorder` with `video/webm` (detect best codec: vp9 > vp8) +- On **Start**: snapshot telemetry, begin recording, show live feed +- On **Stop**: assemble blob, show Discard/Store buttons +- **Store**: upload clip, create attempt record, save metadata + +## Video Replay + +- `