diff --git a/wpimath/src/main/java/edu/wpi/first/math/MathUtil.java b/wpimath/src/main/java/edu/wpi/first/math/MathUtil.java index 95ed5bfe25..9e74aba762 100644 --- a/wpimath/src/main/java/edu/wpi/first/math/MathUtil.java +++ b/wpimath/src/main/java/edu/wpi/first/math/MathUtil.java @@ -145,4 +145,47 @@ public final class MathUtil { public static double interpolate(double startValue, double endValue, double t) { return startValue + (endValue - startValue) * MathUtil.clamp(t, 0, 1); } + + /** + * Checks if the given value matches an expected value within a certain tolerance. + * + * @param expected The expected value + * @param actual The actual value + * @param tolerance The allowed difference between the actual and the expected value + * @return Whether or not the actual value is within the allowed tolerance + */ + public static boolean isNear(double expected, double actual, double tolerance) { + if (tolerance < 0) { + throw new IllegalArgumentException("Tolerance must be a non-negative number!"); + } + return Math.abs(expected - actual) < tolerance; + } + + /** + * Checks if the given value matches an expected value within a certain tolerance. Supports + * continuous input for cases like absolute encoders. + * + *

Continuous input means that the min and max value are considered to be the same point, and + * tolerances can be checked across them. A common example would be for absolute encoders: calling + * isNear(2, 359, 5, 0, 360) returns true because 359 is 1 away from 360 (which is treated as the + * same as 0) and 2 is 2 away from 0, adding up to an error of 3 degrees, which is within the + * given tolerance of 5. + * + * @param expected The expected value + * @param actual The actual value + * @param tolerance The allowed difference between the actual and the expected value + * @param min Smallest value before wrapping around to the largest value + * @param max Largest value before wrapping around to the smallest value + * @return Whether or not the actual value is within the allowed tolerance + */ + public static boolean isNear( + double expected, double actual, double tolerance, double min, double max) { + if (tolerance < 0) { + throw new IllegalArgumentException("Tolerance must be a non-negative number!"); + } + // Max error is exactly halfway between the min and max + double errorBound = (max - min) / 2.0; + double error = MathUtil.inputModulus(expected - actual, -errorBound, errorBound); + return Math.abs(error) < tolerance; + } } diff --git a/wpimath/src/main/native/include/frc/MathUtil.h b/wpimath/src/main/native/include/frc/MathUtil.h index 7947061a81..26f106ef6b 100644 --- a/wpimath/src/main/native/include/frc/MathUtil.h +++ b/wpimath/src/main/native/include/frc/MathUtil.h @@ -105,6 +105,58 @@ constexpr T InputModulus(T input, T minimumInput, T maximumInput) { return input; } +/** + * Checks if the given value matches an expected value within a certain + * tolerance. + * + * @param expected The expected value + * @param actual The actual value + * @param tolerance The allowed difference between the actual and the expected + * value + * @return Whether or not the actual value is within the allowed tolerance + */ +template + requires std::is_arithmetic_v || units::traits::is_unit_t_v +constexpr bool IsNear(T expected, T actual, T tolerance) { + if constexpr (std::is_arithmetic_v) { + return std::abs(expected - actual) < tolerance; + } else { + return units::math::abs(expected - actual) < tolerance; + } +} + +/** + * Checks if the given value matches an expected value within a certain + * tolerance. Supports continuous input for cases like absolute encoders. + * + * Continuous input means that the min and max value are considered to be the + * same point, and tolerances can be checked across them. A common example + * would be for absolute encoders: calling isNear(2, 359, 5, 0, 360) returns + * true because 359 is 1 away from 360 (which is treated as the same as 0) and + * 2 is 2 away from 0, adding up to an error of 3 degrees, which is within the + * given tolerance of 5. + * + * @param expected The expected value + * @param actual The actual value + * @param tolerance The allowed difference between the actual and the expected + * value + * @param min Smallest value before wrapping around to the largest value + * @param max Largest value before wrapping around to the smallest value + * @return Whether or not the actual value is within the allowed tolerance + */ +template + requires std::is_arithmetic_v || units::traits::is_unit_t_v +constexpr bool IsNear(T expected, T actual, T tolerance, T min, T max) { + T errorBound = (max - min) / 2.0; + T error = frc::InputModulus(expected - actual, -errorBound, errorBound); + + if constexpr (std::is_arithmetic_v) { + return std::abs(error) < tolerance; + } else { + return units::math::abs(error) < tolerance; + } +} + /** * Wraps an angle to the range -pi to pi radians (-180 to 180 degrees). * diff --git a/wpimath/src/test/java/edu/wpi/first/math/MathUtilTest.java b/wpimath/src/test/java/edu/wpi/first/math/MathUtilTest.java index 5e1a01e57e..f5764773e6 100644 --- a/wpimath/src/test/java/edu/wpi/first/math/MathUtilTest.java +++ b/wpimath/src/test/java/edu/wpi/first/math/MathUtilTest.java @@ -5,6 +5,8 @@ package edu.wpi.first.math; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import edu.wpi.first.wpilibj.UtilityClassTest; import org.junit.jupiter.api.Test; @@ -93,4 +95,50 @@ class MathUtilTest extends UtilityClassTest { assertEquals(MathUtil.angleModulus(Math.PI / 2), Math.PI / 2); assertEquals(MathUtil.angleModulus(-Math.PI / 2), -Math.PI / 2); } + + @Test + void testIsNear() { + // The answer is always 42 + // Positive integer checks + assertTrue(MathUtil.isNear(42, 42, 1)); + assertTrue(MathUtil.isNear(42, 41, 2)); + assertTrue(MathUtil.isNear(42, 43, 2)); + assertFalse(MathUtil.isNear(42, 44, 1)); + + // Negative integer checks + assertTrue(MathUtil.isNear(-42, -42, 1)); + assertTrue(MathUtil.isNear(-42, -41, 2)); + assertTrue(MathUtil.isNear(-42, -43, 2)); + assertFalse(MathUtil.isNear(-42, -44, 1)); + + // Mixed sign integer checks + assertFalse(MathUtil.isNear(-42, 42, 1)); + assertFalse(MathUtil.isNear(-42, 41, 2)); + assertFalse(MathUtil.isNear(-42, 43, 2)); + assertFalse(MathUtil.isNear(42, -42, 1)); + assertFalse(MathUtil.isNear(42, -41, 2)); + assertFalse(MathUtil.isNear(42, -43, 2)); + + // Floating point checks + assertTrue(MathUtil.isNear(42, 41.5, 1)); + assertTrue(MathUtil.isNear(42, 42.5, 1)); + assertTrue(MathUtil.isNear(42, 41.5, 0.75)); + assertTrue(MathUtil.isNear(42, 42.5, 0.75)); + + // Wraparound checks + assertTrue(MathUtil.isNear(0, 356, 5, 0, 360)); + assertTrue(MathUtil.isNear(0, -356, 5, 0, 360)); + assertTrue(MathUtil.isNear(0, 4, 5, 0, 360)); + assertTrue(MathUtil.isNear(0, -4, 5, 0, 360)); + assertTrue(MathUtil.isNear(400, 41, 5, 0, 360)); + assertTrue(MathUtil.isNear(400, -319, 5, 0, 360)); + assertTrue(MathUtil.isNear(400, 401, 5, 0, 360)); + assertFalse(MathUtil.isNear(0, 356, 2.5, 0, 360)); + assertFalse(MathUtil.isNear(0, -356, 2.5, 0, 360)); + assertFalse(MathUtil.isNear(0, 4, 2.5, 0, 360)); + assertFalse(MathUtil.isNear(0, -4, 2.5, 0, 360)); + assertFalse(MathUtil.isNear(400, 35, 5, 0, 360)); + assertFalse(MathUtil.isNear(400, -315, 5, 0, 360)); + assertFalse(MathUtil.isNear(400, 395, 5, 0, 360)); + } } diff --git a/wpimath/src/test/native/cpp/MathUtilTest.cpp b/wpimath/src/test/native/cpp/MathUtilTest.cpp index a836a77ca9..54ba5b2453 100644 --- a/wpimath/src/test/native/cpp/MathUtilTest.cpp +++ b/wpimath/src/test/native/cpp/MathUtilTest.cpp @@ -117,3 +117,49 @@ TEST(MathUtilTest, AngleModulus) { EXPECT_UNITS_EQ(frc::AngleModulus(units::radian_t{-std::numbers::pi / 2}), units::radian_t{-std::numbers::pi / 2}); } + +TEST(MathUtilTest, IsNear) { + // The answer is always 42 + // Positive integer checks + EXPECT_TRUE(frc::IsNear(42, 42, 1)); + EXPECT_TRUE(frc::IsNear(42, 41, 2)); + EXPECT_TRUE(frc::IsNear(42, 43, 2)); + EXPECT_FALSE(frc::IsNear(42, 44, 1)); + + // Negative integer checks + EXPECT_TRUE(frc::IsNear(-42, -42, 1)); + EXPECT_TRUE(frc::IsNear(-42, -41, 2)); + EXPECT_TRUE(frc::IsNear(-42, -43, 2)); + EXPECT_FALSE(frc::IsNear(-42, -44, 1)); + + // Mixed sign integer checks + EXPECT_FALSE(frc::IsNear(-42, 42, 1)); + EXPECT_FALSE(frc::IsNear(-42, 41, 2)); + EXPECT_FALSE(frc::IsNear(-42, 43, 2)); + EXPECT_FALSE(frc::IsNear(42, -42, 1)); + EXPECT_FALSE(frc::IsNear(42, -41, 2)); + EXPECT_FALSE(frc::IsNear(42, -43, 2)); + + // Floating point checks + EXPECT_TRUE(frc::IsNear(42, 41.5, 1)); + EXPECT_TRUE(frc::IsNear(42, 42.5, 1)); + EXPECT_TRUE(frc::IsNear(42, 41.5, 0.75)); + EXPECT_TRUE(frc::IsNear(42, 42.5, 0.75)); + + // Wraparound checks + EXPECT_TRUE(frc::IsNear(0_deg, 356_deg, 5_deg, 0_deg, 360_deg)); + EXPECT_TRUE(frc::IsNear(0, -356, 5, 0, 360)); + EXPECT_TRUE(frc::IsNear(0, 4, 5, 0, 360)); + EXPECT_TRUE(frc::IsNear(0, -4, 5, 0, 360)); + EXPECT_TRUE(frc::IsNear(400, 41, 5, 0, 360)); + EXPECT_TRUE(frc::IsNear(400, -319, 5, 0, 360)); + EXPECT_TRUE(frc::IsNear(400, 401, 5, 0, 360)); + EXPECT_FALSE(frc::IsNear(0, 356, 2.5, 0, 360)); + EXPECT_FALSE(frc::IsNear(0, -356, 2.5, 0, 360)); + EXPECT_FALSE(frc::IsNear(0, 4, 2.5, 0, 360)); + EXPECT_FALSE(frc::IsNear(0, -4, 2.5, 0, 360)); + EXPECT_FALSE(frc::IsNear(400, 35, 5, 0, 360)); + EXPECT_FALSE(frc::IsNear(400, -315, 5, 0, 360)); + EXPECT_FALSE(frc::IsNear(400, 395, 5, 0, 360)); + EXPECT_FALSE(frc::IsNear(0_deg, -4_deg, 2.5_deg, 0_deg, 360_deg)); +}