diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000000..33edae4117da --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +jobs: + unit-tests: + name: TeamCode unit tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # JDK 17: the project compiles with source/target 8, and JDK 21 emits a + # deprecation warning for that. 17 keeps the build output clean and is + # what AGP 8.7 targets. + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + + # The ubuntu-latest runner ships with the Android SDK preinstalled and + # ANDROID_HOME set, which AGP needs even for local JVM unit tests. + - name: Run unit tests + run: ./gradlew :TeamCode:testDebugUnitTest --no-daemon + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: TeamCode/build/reports/tests/testDebugUnitTest/ + retention-days: 14 diff --git a/TeamCode/README.md b/TeamCode/README.md new file mode 100644 index 000000000000..de2759179627 --- /dev/null +++ b/TeamCode/README.md @@ -0,0 +1,26 @@ +# TeamCode organization + +Robot code belongs under `src/main/java/org/firstinspires/ftc/teamcode`. + +```text +teamcode/ +├── actions/ Reusable, nonblocking robot behaviors +├── autonomous/ +│ ├── paths/ Pedro path and pose definitions +│ └── routines/ Autonomous sequences built from paths and actions +├── config/ Robot-wide names, limits, and setpoints +├── control/ Gamepad mappings and TeleOp subsystem control +├── opmodes/ +│ ├── autonomous/ Competition autonomous entry points +│ ├── teleop/ Competition driver-controlled entry points +│ └── tests/ Test, calibration, and diagnostic OpModes +├── pedro/ Pedro configuration and tuning supplied by Quickstart +├── subsystems/ Hardware-owning classes, grouped by mechanism +└── util/ Small helpers with no mechanism ownership +``` + +Keep registered OpModes thin. They should select and coordinate behavior while +subsystems own hardware access and mechanism state. Add a child package under +`subsystems` when a mechanism is known instead of creating speculative mechanism +folders at the start of the season. + diff --git a/TeamCode/build.gradle b/TeamCode/build.gradle index f7307a8ac37e..6e21c18b8d15 100644 --- a/TeamCode/build.gradle +++ b/TeamCode/build.gradle @@ -22,8 +22,21 @@ android { packagingOptions { jniLibs.useLegacyPackaging true } + + testOptions { + unitTests { + // Stub out android.* calls (e.g. android.util.Log) instead of throwing + // "not mocked". Pure classes like Rates don't need this, but anything + // that touches an SDK type will. + returnDefaultValues = true + } + } } dependencies { implementation project(':FtcRobotController') + + // Local JVM unit tests (src/test/java). Run with: + // ./gradlew :TeamCode:testDebugUnitTest + testImplementation 'junit:junit:4.13.2' } diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/actions/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/actions/package-info.java new file mode 100644 index 000000000000..1e809f012e61 --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/actions/package-info.java @@ -0,0 +1,6 @@ +/** + * Reusable, nonblocking behaviors that coordinate one or more subsystems. + * Actions may be shared by autonomous and TeleOp code. + */ +package org.firstinspires.ftc.teamcode.actions; + diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/autonomous/paths/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/autonomous/paths/package-info.java new file mode 100644 index 000000000000..ed95c2151ed0 --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/autonomous/paths/package-info.java @@ -0,0 +1,5 @@ +/** + * Pedro path, pose, and route definitions with no OpMode registration. + */ +package org.firstinspires.ftc.teamcode.autonomous.paths; + diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/autonomous/routines/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/autonomous/routines/package-info.java new file mode 100644 index 000000000000..78a244bd7d14 --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/autonomous/routines/package-info.java @@ -0,0 +1,5 @@ +/** + * Reusable autonomous sequences composed from paths, actions, and subsystems. + */ +package org.firstinspires.ftc.teamcode.autonomous.routines; + diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/config/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/config/package-info.java new file mode 100644 index 000000000000..06e6f226475f --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/config/package-info.java @@ -0,0 +1,5 @@ +/** + * Robot-wide hardware names, operating limits, setpoints, and season configuration. + */ +package org.firstinspires.ftc.teamcode.config; + diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/control/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/control/package-info.java new file mode 100644 index 000000000000..ec4611e252a4 --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/control/package-info.java @@ -0,0 +1,5 @@ +/** + * Gamepad mappings and TeleOp controllers. Control classes translate driver input + * into subsystem calls without owning hardware devices or mechanism state. + */ +package org.firstinspires.ftc.teamcode.control; diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/autonomous/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/autonomous/package-info.java new file mode 100644 index 000000000000..bf56970f07b8 --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/autonomous/package-info.java @@ -0,0 +1,6 @@ +/** + * Registered competition autonomous OpModes. Keep entry points thin and delegate + * reusable behavior to autonomous routines, actions, and subsystems. + */ +package org.firstinspires.ftc.teamcode.opmodes.autonomous; + diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/teleop/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/teleop/package-info.java new file mode 100644 index 000000000000..a509aab8e91b --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/teleop/package-info.java @@ -0,0 +1,5 @@ +/** + * Registered competition driver-controlled OpModes. + */ +package org.firstinspires.ftc.teamcode.opmodes.teleop; + diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/tests/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/tests/package-info.java new file mode 100644 index 000000000000..1a130ac93a7a --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/opmodes/tests/package-info.java @@ -0,0 +1,5 @@ +/** + * Test, calibration, and diagnostic OpModes. + */ +package org.firstinspires.ftc.teamcode.opmodes.tests; + diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/subsystems/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/subsystems/package-info.java new file mode 100644 index 000000000000..22a5aa88d7b2 --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/subsystems/package-info.java @@ -0,0 +1,6 @@ +/** + * Robot mechanisms that own their hardware devices and internal control state. + * Create one child package per mechanism when the robot design is established. + */ +package org.firstinspires.ftc.teamcode.subsystems; + diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/util/Rates.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/util/Rates.java new file mode 100644 index 000000000000..8865b6bc07af --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/util/Rates.java @@ -0,0 +1,52 @@ +package org.firstinspires.ftc.teamcode.util; + +public class Rates { + + private double centerRate; + private double maxRate; + private double expo; + private double deadzone; + + public Rates(double centerRate, double maxRate, double expo, double deadzone) { + if (centerRate > maxRate) { + throw new IllegalArgumentException("Center rate must be less than max rate."); + } + + this.centerRate = centerRate; + this.maxRate = maxRate; + this.expo = expo; + this.deadzone = deadzone; + } + + /** + * apply Deadzone prevents robot twitches while the joystick is moving without intent. + * @param stickPosition + * @return + */ + private double applyDeadzone(double stickPosition) { + double magnitude = Math.abs(stickPosition); + if (magnitude < deadzone) { + return 0.0; + } + + return Math.signum(stickPosition) * (magnitude - deadzone) / (1 - deadzone); + } + + /** + * + * @param stickPosition A parameter read from a controller that should be in the domain {0..1} + * @return A power value representative of what we want to hand to a Drive train / motors + */ + public double apply(double stickPosition) { + if (Math.abs(stickPosition) > 1) { + throw new IllegalArgumentException("Stick Position cannot be more than 1 or less than -1"); + } + + stickPosition = applyDeadzone(stickPosition); + double actualRatesExpoFactor = + Math.abs(stickPosition) * (Math.pow(stickPosition, 5) * expo + stickPosition * (1-expo)); + + return (centerRate * stickPosition) + + ((maxRate - centerRate) * actualRatesExpoFactor); + } +} diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/util/package-info.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/util/package-info.java new file mode 100644 index 000000000000..0e614a548f97 --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/util/package-info.java @@ -0,0 +1,5 @@ +/** + * Small, broadly reusable helpers that do not own robot mechanisms. + */ +package org.firstinspires.ftc.teamcode.util; + diff --git a/TeamCode/src/test/java/org/firstinspires/ftc/teamcode/util/RatesTest.java b/TeamCode/src/test/java/org/firstinspires/ftc/teamcode/util/RatesTest.java new file mode 100644 index 000000000000..e8a5732fc925 --- /dev/null +++ b/TeamCode/src/test/java/org/firstinspires/ftc/teamcode/util/RatesTest.java @@ -0,0 +1,244 @@ +package org.firstinspires.ftc.teamcode.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Unit tests for the Betaflight-style "Actual Rates" curve. + * + * These run on the local JVM -- no phone, no emulator, no Robolectric -- because + * Rates imports nothing but java.lang.Math. Run them with: + * + * ./gradlew :TeamCode:testDebugUnitTest + * + * The curve maps a stick position in [-1, 1] to a motor command in [-1, 1] and is + * defined by three properties, which the first group of tests pins down: + * + * 1. center stick produces no output + * 2. full stick produces exactly maxRate + * 3. the slope of the curve at center stick is exactly centerRate + * + * Property 3 is what makes the three knobs independent of each other, which is the + * whole reason to prefer Actual Rates over the older Betaflight rate system. + */ +public class RatesTest { + + /** Tolerance for exact algebraic identities. */ + private static final double EPS = 1e-9; + + /** A representative profile used by most tests. */ + private static Rates profile() { + return new Rates(0.35, 1.0, 0.6, 0.0); + } + + // ---------------------------------------------------------------- // + // The three defining properties // + // ---------------------------------------------------------------- // + + @Test + public void centerStickProducesNoOutput() { + assertEquals(0.0, profile().apply(0.0), EPS); + } + + @Test + public void fullForwardStickIsExactlyMaxRate() { + assertEquals(1.0, profile().apply(1.0), EPS); + } + + @Test + public void fullReverseStickIsExactlyNegativeMaxRate() { + assertEquals(-1.0, profile().apply(-1.0), EPS); + } + + @Test + public void slopeAtCenterEqualsCenterRate() { + Rates r = profile(); + double h = 1e-6; + double slope = (r.apply(h) - r.apply(0.0)) / h; + assertEquals(0.35, slope, 1e-4); + } + + // ---------------------------------------------------------------- // + // Sign handling // + // ---------------------------------------------------------------- // + + @Test + public void reverseStickProducesReverseOutput() { + Rates r = profile(); + for (double x = -1.0; x <= -0.02; x += 0.01) { + assertTrue("stick " + x + " produced non-negative output " + r.apply(x), + r.apply(x) < 0.0); + } + } + + @Test + public void curveIsOddSymmetric() { + Rates r = profile(); + for (double x = 0.0; x <= 1.0; x += 0.01) { + assertEquals("asymmetry at stick " + x, + -r.apply(x), r.apply(-x), EPS); + } + } + + // ---------------------------------------------------------------- // + // Shape invariants across the whole stick range // + // ---------------------------------------------------------------- // + + @Test + public void curveIsMonotonic() { + Rates r = profile(); + double previous = r.apply(-1.0); + for (double x = -1.0; x <= 1.0; x += 0.001) { + double out = r.apply(x); + assertTrue("output decreased at stick " + x, out >= previous - EPS); + previous = out; + } + } + + @Test + public void outputNeverExceedsMaxRate() { + Rates r = profile(); + for (double x = -1.0; x <= 1.0; x += 0.001) { + assertTrue("stick " + x + " commanded " + r.apply(x), + Math.abs(r.apply(x)) <= 1.0 + EPS); + } + } + + @Test + public void degeneratesToIdentityWhenCenterEqualsMaxAndNoExpo() { + // With no gap between center and max, and no expo, the curve is a straight + // line. This is the one case that can be checked entirely by hand. + Rates r = new Rates(1.0, 1.0, 0.0, 0.0); + for (double x = -1.0; x <= 1.0; x += 0.05) { + assertEquals("not linear at stick " + x, x, r.apply(x), EPS); + } + } + + // ---------------------------------------------------------------- // + // What the two tuning knobs actually do // + // ---------------------------------------------------------------- // + + @Test + public void moreExpoSoftensMidStick() { + double soft = new Rates(0.35, 1.0, 0.8, 0.0).apply(0.5); + double firm = new Rates(0.35, 1.0, 0.2, 0.0).apply(0.5); + assertTrue("more expo must mean less output at mid stick", soft < firm); + } + + @Test + public void moreCenterRateSharpensSmallMovements() { + double gentle = new Rates(0.20, 1.0, 0.6, 0.0).apply(0.1); + double eager = new Rates(0.50, 1.0, 0.6, 0.0).apply(0.1); + assertTrue("more center rate must mean more output near center", eager > gentle); + } + + @Test + public void expoDoesNotChangeFullStickOutput() { + // Expo reshapes the middle of the curve only; the endpoint stays pinned. + for (double expo = 0.0; expo <= 1.0; expo += 0.1) { + assertEquals("expo " + expo + " moved the endpoint", + 1.0, new Rates(0.35, 1.0, expo, 0.0).apply(1.0), EPS); + } + } + + // ---------------------------------------------------------------- // + // Deadzone // + // ---------------------------------------------------------------- // + + @Test + public void insideDeadzoneProducesNoOutput() { + Rates r = new Rates(0.35, 1.0, 0.6, 0.05); + assertEquals(0.0, r.apply(0.049), EPS); + assertEquals(0.0, r.apply(-0.049), EPS); + } + + @Test + public void leavingDeadzoneIsContinuous() { + // A deadzone that zeroes below the threshold without rescaling above it + // produces a step change the driver feels as a jolt. + Rates r = new Rates(0.35, 1.0, 0.6, 0.05); + assertTrue("output jumped on leaving the deadzone", + Math.abs(r.apply(0.051)) < 0.01); + assertTrue("output jumped on leaving the deadzone (reverse)", + Math.abs(r.apply(-0.051)) < 0.01); + } + + @Test + public void deadzoneDoesNotReduceFullStickOutput() { + // The rescale divisor is what keeps full stick reaching full output. + Rates r = new Rates(0.35, 1.0, 0.6, 0.05); + assertEquals(1.0, r.apply(1.0), EPS); + assertEquals(-1.0, r.apply(-1.0), EPS); + } + + // ---------------------------------------------------------------- // + // Input and parameter guards // + // ---------------------------------------------------------------- // + + @Test(expected = IllegalArgumentException.class) + public void rejectsCenterRateAboveMaxRate() { + // Otherwise the curve overshoots maxRate at full stick. + new Rates(1.5, 1.0, 0.6, 0.0); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsOutOfRangeStickInput() { + // Nothing should be able to command more than maxRate, even if a caller + // hands us a badly scaled value. + profile().apply(1.4); + } + + // ---------------------------------------------------------------- // + // Characterization of the profiles MainTeleOp actually drives with // + // ---------------------------------------------------------------- // + + /** + * Golden values for the tuned profiles. These are not derived from the + * implementation -- they are the reference Actual Rates curve. If someone + * changes the formula these fail; if someone deliberately retunes the + * profile in MainTeleOp, update these to match. + * + * This table is also directly comparable to the curve drawn by the + * Betaflight configurator for the same three values. + */ + @Test + public void moveRateProfileMatchesReferenceCurve() { + Rates moveRate = new Rates(0.7, 1.0, 0.4, 0.01); + double[] expected = { + 0.0000000000, 0.0651240347, 0.1409793665, 0.2205716833, + 0.3041399552, 0.3923243418, 0.4864782117, 0.5890719320, + 0.7041884285, 0.8381105161, 1.0000000000, + }; + for (int i = 0; i < expected.length; i++) { + double stick = i / 10.0; + assertEquals("moveRate at stick " + stick, + expected[i], moveRate.apply(stick), 1e-9); + } + } + + @Test + public void turnRateProfileMatchesReferenceCurve() { + Rates turnRate = new Rates(0.375, 1.0, 0.4, 0.01); + double[] expected = { + 0.0000000000, 0.0371902238, 0.0857945556, 0.1421842728, + 0.2068572298, 0.2811470926, 0.3678733788, 0.4721826866, + 0.6025811115, 0.7721578513, 1.0000000000, + }; + for (int i = 0; i < expected.length; i++) { + double stick = i / 10.0; + assertEquals("turnRate at stick " + stick, + expected[i], turnRate.apply(stick), 1e-9); + } + } + + @Test + public void turnRateIsGentlerThanMoveRateNearCenter() { + // The intent documented in MainTeleOp: turning should be less twitchy + // than translating for the same small stick movement. + Rates moveRate = new Rates(0.7, 1.0, 0.4, 0.01); + Rates turnRate = new Rates(0.375, 1.0, 0.4, 0.01); + assertTrue(turnRate.apply(0.2) < moveRate.apply(0.2)); + } +}