#include "native_imu.h" #include #include #include #include #include namespace { int failures = 0; using Vector = std::array; using Quaternion = std::array; void expect(bool okay, const char* message) { if (!okay) { std::cerr << message << '\n'; ++failures; } } // Deliberately decode bit-by-bit rather than mirroring the encoder's word // boundaries. Double precision here is an independent host-side reference. uint32_t bits(const uint8_t* bytes, unsigned start, unsigned width) { uint32_t result = 0; for (unsigned i = 0; i < width; ++i) { result |= static_cast((bytes[(start + i) / 8] >> ((start + i) % 8)) & 1) << i; } return result; } int64_t signed_bits(const uint8_t* bytes, unsigned start, unsigned width) { const uint32_t value = bits(bytes, start, width); return value & (UINT32_C(1) << (width - 1)) ? static_cast(value) - (INT64_C(1) << width) : value; } struct Decoded { Quaternion q{}; Vector acceleration{}; std::array acceleration_raw{}; uint32_t counter = 0; uint32_t elapsed = 0; int64_t temperature = 0; }; Decoded decode(const uint8_t* bytes) { Decoded result; expect(bytes[3] == 0x0c, "wire block must use the recovered one-sample format"); result.counter = bits(bytes, 0, 12); result.elapsed = bits(bytes, 12, 12); result.temperature = signed_bits(bytes, 224, 16); const unsigned largest = bits(bytes, 32, 3); expect(largest < 4, "wire quaternion tag must identify an actual component"); if (largest >= 4) return result; Vector ratios{}; double length_squared = 1.0; for (unsigned i = 0; i < 3; ++i) { ratios[i] = bits(bytes, 35 + 31 * i, 31) / 1073741824.0 - 1.0; length_squared += ratios[i] * ratios[i]; result.acceleration_raw[i] = signed_bits(bytes, 128 + 32 * i, 32); result.acceleration[i] = result.acceleration_raw[i] / 268435456.0; } result.q[largest] = 1.0 / std::sqrt(length_squared); for (unsigned i = 0; i < 3; ++i) result.q[(largest + i + 1) & 3] = ratios[i] * result.q[largest]; return result; } Vector rotate(const Quaternion& q, const Vector& v) { const Vector cross{q[2] * v[2] - q[3] * v[1], q[3] * v[0] - q[1] * v[2], q[1] * v[1] - q[2] * v[0]}; return {v[0] + 2.0 * (q[0] * cross[0] + q[2] * cross[2] - q[3] * cross[1]), v[1] + 2.0 * (q[0] * cross[1] + q[3] * cross[0] - q[1] * cross[2]), v[2] + 2.0 * (q[0] * cross[2] + q[1] * cross[1] - q[2] * cross[0])}; } bool close(const Vector& a, const Vector& b, double tolerance = 0.00002) { for (unsigned i = 0; i < 3; ++i) { if (!std::isfinite(a[i]) || std::abs(a[i] - b[i]) > tolerance) return false; } return true; } Quaternion normalized(const float input[4]) { double squared = 0.0; for (unsigned i = 0; i < 4; ++i) squared += static_cast(input[i]) * input[i]; Quaternion result{}; for (unsigned i = 0; i < 4; ++i) result[i] = input[i] / std::sqrt(squared); return result; } double camera_heading(const ProbeNativeMotion& motion) { const Vector forward = rotate(normalized(motion.quaternion()), {0.0, 1.0, 0.0}); return std::atan2(forward[1], forward[0]); } double heading_change(const ProbeNativeMotion& motion, double initial_heading) { return std::remainder(camera_heading(motion) - initial_heading, 2.0 * std::acos(-1.0)); } void check_encoded_orientation(const float q[4], const float accel[3]) { std::array guarded{}; guarded.front() = 0x53; guarded.back() = 0x79; expect(probe_native_imu_pack(q, accel, 0xabc, 0xdef, -12345, guarded.data() + 1), "finite nonzero orientation must encode"); expect(guarded.front() == 0x53 && guarded.back() == 0x79, "encoder must write exactly thirty bytes"); const auto decoded = decode(guarded.data() + 1); expect(decoded.counter == 0xabc && decoded.elapsed == 0xdef && decoded.temperature == -12345, "wire counters and signed temperature must decode independently"); for (unsigned axis = 0; axis < 3; ++axis) { Vector basis{}; basis[axis] = 1.0; expect(close(rotate(decoded.q, basis), rotate(normalized(q), basis)), "wire orientation must preserve physical rotation for every largest-component choice"); } expect(close(decoded.acceleration, {accel[0], accel[1], accel[2]}, 0.0000001), "wire acceleration must preserve signed Q28 g units"); } void codec_wire_edges() { const float accel[3]{1.25f, -2.5f, 0.0625f}; const float orientations[][4]{{4.0f, -1.0f, 2.0f, -3.0f}, {-0.5f, -3.0f, 0.25f, 1.0f}, {1.0f, -2.0f, 4.0f, -0.5f}, {-1.0f, 0.5f, -2.0f, -4.0f}, {1.0f, -1.0f, 1.0f, 1.0f}}; for (const auto& q : orientations) check_encoded_orientation(q, accel); const float large = std::numeric_limits::max(); const float tiny = std::numeric_limits::denorm_min(); const float large_q[4]{large, -large, large, -large}; const float tiny_q[4]{tiny, -tiny, tiny, -tiny}; check_encoded_orientation(large_q, accel); check_encoded_orientation(tiny_q, accel); uint8_t bytes[30]{}; const float identity[4]{1.0f, 0.0f, 0.0f, 0.0f}; const float saturated[3]{large, -large, -8.0f}; expect(probe_native_imu_pack(identity, saturated, 0, 4095, -32768, bytes), "finite out-of-range acceleration must saturate"); const auto limits = decode(bytes); expect(limits.acceleration_raw == std::array{INT32_MAX, INT32_MIN, INT32_MIN}, "positive saturation must never wrap to negative acceleration"); expect(limits.counter == 0 && limits.elapsed == 4095 && limits.temperature == -32768, "wire signed and unsigned field boundaries must survive encoding"); const float ties[3]{0.5f / 268435456.0f, 1.5f / 268435456.0f, -2.5f / 268435456.0f}; const float fine_q[4]{1.0f, 5.5f / 1073741824.0f, -5.5f / 1073741824.0f, 0.0f}; expect(probe_native_imu_pack(fine_q, ties, 0, 0, 0, bytes), "sub-count finite inputs must encode"); expect(decode(bytes).acceleration_raw == std::array{0, 2, -2}, "Q28 halfway values use nearest-even rounding"); expect(bits(bytes, 35, 31) == UINT32_C(0x40000006) && bits(bytes, 66, 31) == UINT32_C(0x3ffffffa), "adding the ratio midpoint must not erase low quaternion bits"); } void codec_rejects_invalid_inputs() { float q[4]{1.0f, 0.0f, 0.0f, 0.0f}; float accel[3]{0.0f, 0.0f, 1.0f}; std::array output; output.fill(0xa5); const auto unchanged = output; const float invalid[]{std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::infinity()}; for (float value : invalid) { q[2] = value; expect(!probe_native_imu_pack(q, accel, 1, 1, 0, output.data()), "nonfinite quaternion must be rejected"); q[2] = 0.0f; accel[1] = value; expect(!probe_native_imu_pack(q, accel, 1, 1, 0, output.data()), "nonfinite acceleration must be rejected, not saturated"); accel[1] = 0.0f; } q[0] = 0.0f; expect(!probe_native_imu_pack(q, accel, 1, 1, 0, output.data()), "zero quaternion cannot identify a real orientation"); q[0] = 1.0f; expect(!probe_native_imu_pack(q, accel, 4096, 1, 0, output.data()), "counter overflow must fail rather than overwrite elapsed bits"); expect(!probe_native_imu_pack(q, accel, 1, 4096, 0, output.data()), "elapsed overflow must fail rather than overwrite format bits"); expect(output == unchanged, "failed encoding must leave the caller's pending packet unchanged"); } struct Rig { ProbeNativeMotion motion; ProbeNativeMotionSample sample{}; uint32_t now; uint32_t generation = 1; static constexpr float residual[3]{0.0f, 0.0f, 0.0f}; ProbeNativeMotionBias policy = ProbeNativeMotionBias::kAlreadyCalibrated; explicit Rig(uint32_t start = 0, ProbeNativeMotionBias mode = ProbeNativeMotionBias::kAlreadyCalibrated) : now(start), policy(mode) { sample.accel_valid = sample.gyro_valid = true; sample.accel_g[2] = 1.0f; rest(); } void rest() { for (unsigned i = 0; i < 3; ++i) sample.gyro_dps[i] = residual[i]; } void fresh(uint32_t elapsed = 25000, bool accel = true, bool gyro = true) { now += elapsed; if (accel) { ++sample.accel_sequence; sample.accel_us = now; } if (gyro) { ++sample.gyro_sequence; sample.gyro_us = now; } motion.update(now, generation, sample, policy); } void settle() { for (unsigned i = 0; i < 65; ++i) fresh(); expect(motion.ready(), "fresh stationary sensors must finish residual bias calibration"); } Decoded packet() { uint8_t bytes[30]{}; expect(motion.ready(), "only calibrated fresh motion may be packed for the consumer"); expect(probe_native_imu_pack(motion.quaternion(), motion.acceleration(), 0, 0, 0, bytes), "live motion must be encodable"); return decode(bytes); } }; void background_bias_requires_quiet_samples_not_startup_delay() { Rig fast(0, ProbeNativeMotionBias::kTrackStationary); fast.sample.gyro_dps[2] = 1.0f; fast.fresh(0); expect(fast.motion.ready(), "Wii motion must be available on the first real sensor pair"); for (unsigned i = 0; i < 63; ++i) fast.fresh(1000); expect(fast.motion.bias()[2] == 0.0f, "packet count alone cannot establish a bias target"); for (unsigned i = 0; i < 56; ++i) fast.fresh(); expect(fast.motion.bias()[2] == 0.0f, "partial quiet windows cannot change gyro correction"); const double before = camera_heading(fast.motion); fast.fresh(37000); expect(fast.motion.ready() && fast.motion.bias()[2] == 0.0f, "learning a target must not reset or snap motion"); expect(std::abs(heading_change(fast.motion, before) - 0.037 * std::acos(-1.0) / 180.0) < 0.000001, "calibration completion must preserve accumulated yaw"); fast.fresh(); expect(fast.motion.bias()[2] > 0.0f && fast.motion.bias()[2] <= 0.125001f, "a confirmed target must be applied with a bounded slew, not a jump"); Rig slow(0, ProbeNativeMotionBias::kTrackStationary); slow.sample.gyro_dps[2] = 1.0f; slow.fresh(0); for (unsigned i = 0; i < 62; ++i) slow.fresh(30000); expect(slow.motion.ready() && slow.motion.bias()[2] == 0.0f, "elapsed time without enough distinct samples cannot learn bias"); slow.fresh(30000); slow.fresh(0); expect(slow.motion.bias()[2] == 0.0f, "zero elapsed time cannot increase correction strength"); slow.fresh(25000); expect(slow.motion.bias()[2] > 0.0f, "a complete quiet window must refine bias without gating output"); } void quantized_wii_bias_calibrates_and_integrates() { Rig rig(0, ProbeNativeMotionBias::kTrackStationary); constexpr float device_bias[3]{-13.0625f, 12.4375f, -13.125f}; constexpr int noise_q10[8]{-576, 192, -320, 576, -192, 320, -64, 64}; const Vector gravity{8352.0 / 8192.0, 290.0 / 8192.0, -298.0 / 8192.0}; // Deterministic Wii-scale Q13 steps and Q10 noise, with accel and gyro // arriving independently at 200 / 100 Hz. Neither noise nor bias is motion. for (unsigned i = 0; i < 4000; ++i) { rig.sample.accel_g[0] = (8352 + (i & 1 ? 80 : -80)) / 8192.0f; rig.sample.accel_g[1] = (290 + (i & 2 ? 40 : -40)) / 8192.0f; rig.sample.accel_g[2] = (-298 + (i & 4 ? 43 : -43)) / 8192.0f; if (i % 2 == 0) { for (unsigned axis = 0; axis < 3; ++axis) { rig.sample.gyro_dps[axis] = device_bias[axis] + noise_q10[(i / 2 + axis * 3) % 8] / 1024.0f; } } rig.fresh(5000, true, i % 2 == 0); expect(rig.motion.ready(), "quantized Wii motion must remain available throughout background learning"); } expect(rig.motion.ready(), "a large measured Wii residual must remain correctable in the background"); expect(close({rig.motion.bias()[0], rig.motion.bias()[1], rig.motion.bias()[2]}, {device_bias[0], device_bias[1], device_bias[2]}, 0.02), "quiet sensor observations must converge to the measured device bias"); const Quaternion reference = rig.packet().q; const double gravity_length = std::sqrt(gravity[0] * gravity[0] + gravity[1] * gravity[1] + gravity[2] * gravity[2]); expect(close(rotate(reference, gravity), {0.0, 0.0, gravity_length}, 0.0002), "quantized gravity must establish the physical reference without rescaling acceleration"); for (unsigned axis = 0; axis < 3; ++axis) { rig.sample.gyro_dps[axis] = device_bias[axis]; rig.sample.accel_g[axis] = static_cast(gravity[axis]); } rig.fresh(0); for (unsigned i = 0; i < 100; ++i) rig.fresh(10000); expect(close(rotate(rig.packet().q, {1.0, 0.0, 0.0}), rotate(reference, {1.0, 0.0, 0.0}), 0.001), "learned large residual bias must not become stationary orientation drift"); rig.sample.gyro_dps[2] += 90.0f; rig.fresh(0); for (unsigned i = 1; i <= 100; ++i) { const double angle = i * std::acos(-1.0) / 200.0; rig.sample.accel_g[0] = static_cast(std::cos(angle) * gravity[0] + std::sin(angle) * gravity[1]); rig.sample.accel_g[1] = static_cast(-std::sin(angle) * gravity[0] + std::cos(angle) * gravity[1]); rig.fresh(10000); } expect(close(rotate(rig.packet().q, {1.0, 0.0, 0.0}), rotate(reference, {0.0, 1.0, 0.0}), 0.001), "one second of body rotation must subtract the measured Wii bias before integration"); } void measured_gravity_sets_reference() { const float poses[][3]{{1.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f}, {0.000001f, 0.000002f, -1.0f}, {0.3f, 0.4f, 0.8660254f}}; for (const auto& pose : poses) { Rig rig; for (unsigned i = 0; i < 3; ++i) rig.sample.accel_g[i] = pose[i]; rig.settle(); const auto decoded = rig.packet(); expect(close(rotate(decoded.q, decoded.acceleration), {0.0, 0.0, 1.0}), "measured gravity, including anti-parallel gravity, must align with reference +Z"); expect(close(decoded.acceleration, {pose[0], pose[1], pose[2]}, 0.0000001), "reference initialization must not replace the real body acceleration"); } } void bias_corrected_body_rotation_reaches_wire() { Rig rig; rig.sample.accel_g[0] = 1.0f; rig.sample.accel_g[2] = 0.0f; rig.settle(); for (unsigned i = 0; i < 100; ++i) rig.fresh(10000); expect(close(rotate(rig.packet().q, {1.0, 0.0, 0.0}), {0.0, 0.0, 1.0}), "stationary residual bias must not rotate the initialized orientation"); rig.sample.gyro_dps[2] += 90.0f; rig.fresh(0); for (unsigned i = 1; i <= 100; ++i) { const double angle = i * std::acos(-1.0) / 200.0; rig.sample.accel_g[0] = static_cast(std::cos(angle)); rig.sample.accel_g[1] = static_cast(-std::sin(angle)); rig.fresh(10000); } const auto turned = rig.packet(); expect(close(rotate(turned.q, {1.0, 0.0, 0.0}), {0.0, 1.0, 0.0}) && close(rotate(turned.q, {0.0, 1.0, 0.0}), {0.0, 0.0, -1.0}) && close(rotate(turned.q, {0.0, 0.0, 1.0}), {-1.0, 0.0, 0.0}), "one second of corrected body-Z rotation must compose after the nonidentity gravity alignment"); rig.sample.accel_g[0] = 1.6f; rig.sample.accel_g[1] = -0.3f; rig.sample.accel_g[2] = 0.8f; rig.fresh(0); expect(close(rig.packet().acceleration, {1.6, -0.3, 0.8}, 0.0000001), "ready packets must retain real dynamic acceleration"); expect(close({rig.motion.bias()[0], rig.motion.bias()[1], rig.motion.bias()[2]}, {Rig::residual[0], Rig::residual[1], Rig::residual[2]}, 0.000001), "motion after calibration must not relearn or chase the gyro bias"); } void elapsed_time_not_packet_count_controls_rotation() { for (uint32_t interval : {5000u, 25000u}) { Rig rig; rig.settle(); rig.sample.gyro_dps[2] += 120.0f; rig.fresh(0); for (uint32_t elapsed = interval; elapsed <= 1000000; elapsed += interval) { rig.fresh(interval, true, elapsed % 100000 == 0); } expect(close(rotate(rig.packet().q, {1.0, 0.0, 0.0}), {-0.5, std::sqrt(0.75), 0.0}), "a fresh held gyro rate must integrate elapsed time regardless of polling cadence"); } Rig split; split.settle(); split.sample.gyro_dps[2] += 90.0f; ++split.sample.gyro_sequence; split.sample.gyro_us = split.now + 10000; split.fresh(40000, true, false); const double angle = 2.7 * std::acos(-1.0) / 180.0; expect(close(rotate(split.packet().q, {1.0, 0.0, 0.0}), {std::cos(angle), std::sin(angle), 0.0}), "a rate arriving inside an update interval must only affect time after its receipt"); } void moving_startup_does_not_calibrate() { for (unsigned movement = 0; movement < 4; ++movement) { Rig rig(0, ProbeNativeMotionBias::kTrackStationary); for (unsigned i = 0; i < 100; ++i) { rig.rest(); rig.sample.accel_g[0] = 0.0f; rig.sample.accel_g[1] = 0.0f; rig.sample.accel_g[2] = 1.0f; if (movement == 0) { // A real slow body-X turn changes gravity even with a constant // gyro reading. Its per-packet tilt is smaller than Wii noise. const float angle = static_cast(i) * 0.025f * 2.0f * static_cast(std::acos(-1.0) / 180.0); rig.sample.gyro_dps[0] += 2.0f; rig.sample.accel_g[1] = std::sin(angle); rig.sample.accel_g[2] = std::cos(angle); } if (movement == 1) rig.sample.accel_g[2] = 0.5f; if (movement == 2) rig.sample.accel_g[0] = i & 1 ? 0.04f : -0.04f; if (movement == 3) rig.sample.gyro_dps[0] = i & 1 ? 1.0f : -1.0f; rig.fresh(); } expect(rig.motion.ready() && rig.motion.bias()[0] == 0.0f && rig.motion.bias()[1] == 0.0f && rig.motion.bias()[2] == 0.0f, "moving startup must stay live without learning movement as bias"); rig.rest(); rig.sample.accel_g[0] = 0.0f; rig.sample.accel_g[1] = 0.0f; rig.sample.accel_g[2] = 1.0f; rig.sample.gyro_dps[2] = 1.0f; for (unsigned i = 0; i < 128; ++i) rig.fresh(); expect(rig.motion.ready() && rig.motion.bias()[2] > 0.0f, "background correction must recover after movement"); } Rig contaminated(0, ProbeNativeMotionBias::kTrackStationary); contaminated.sample.gyro_dps[2] = 1.0f; for (unsigned i = 0; i < 40; ++i) contaminated.fresh(); contaminated.sample.gyro_dps[0] = 4.0f; contaminated.fresh(); contaminated.rest(); contaminated.sample.gyro_dps[2] = 1.0f; for (unsigned i = 0; i < 40; ++i) contaminated.fresh(); expect(contaminated.motion.ready() && contaminated.motion.bias()[2] == 0.0f, "a gyro impulse must discard, not pool, the partial bias window"); for (unsigned i = 0; i < 24; ++i) contaminated.fresh(); expect(contaminated.motion.bias()[2] == 0.0f, "a completed estimate must not snap the current correction"); contaminated.fresh(); expect(contaminated.motion.bias()[2] > 0.0f, "a replacement quiet window must eventually refine bias"); Rig interleaved(0, ProbeNativeMotionBias::kTrackStationary); interleaved.sample.gyro_dps[2] = 1.0f; for (unsigned i = 0; i < 55; ++i) interleaved.fresh(); interleaved.sample.accel_g[0] = 0.1f; interleaved.fresh(5000, true, false); interleaved.sample.accel_g[0] = 0.0f; interleaved.fresh(5000, true, false); for (unsigned i = 0; i < 20; ++i) interleaved.fresh(); expect(interleaved.motion.ready() && interleaved.motion.bias()[2] == 0.0f, "acceleration-only movement must reset bias collection without blocking motion"); interleaved.settle(); } void duplicate_sequences_and_interleaved_recovery() { Rig rig; rig.fresh(0); for (unsigned i = 0; i < 400; ++i) { // A caller cannot renew sensor lifetime by relabelling receipt time // while leaving the actual parser sequence unchanged. rig.sample.accel_us = rig.sample.gyro_us = rig.now + 5000; rig.fresh(5000, false, false); } expect(!rig.motion.ready(), "duplicate samples cannot establish calibrated sensor readiness"); for (unsigned i = 0; i < 128; ++i) rig.fresh(25000, i % 2 == 0, i % 2 != 0); expect(rig.motion.ready(), "fresh independently interleaved sensors must recover after a stale reset"); rig.sample.gyro_dps[2] += 100.0f; rig.fresh(10000, false, false); expect(close(rotate(rig.packet().q, {1.0, 0.0, 0.0}), {1.0, 0.0, 0.0}), "mutating a duplicate sequence must not inject a new angular rate"); } void lifecycle_invalidates_bias_and_orientation() { for (unsigned fault = 0; fault < 7; ++fault) { Rig rig(0, ProbeNativeMotionBias::kTrackStationary); rig.sample.gyro_dps[2] = 1.0f; for (unsigned i = 0; i < 400; ++i) rig.fresh(10000); expect(rig.motion.bias()[2] == 1.0f, "quiet samples must establish a correction before a lifecycle fault"); if (fault == 0) rig.sample.accel_valid = false; if (fault == 1) rig.sample.gyro_valid = false; if (fault == 2) rig.sample.accel_g[0] = std::numeric_limits::quiet_NaN(); if (fault == 3) rig.sample.gyro_dps[0] = std::numeric_limits::infinity(); if (fault == 4) ++rig.generation; if (fault == 5) rig.motion.reset(); rig.fresh(fault == 6 ? 50001 : 1000); expect(rig.motion.ready() == (fault >= 4), "invalid sensors fail closed; fresh sensors after reset recover immediately"); expect(rig.motion.bias()[2] == 0.0f, "faults must retire the old correction and its target"); ++rig.generation; rig.sample.accel_valid = rig.sample.gyro_valid = true; rig.sample.accel_g[0] = rig.sample.accel_g[1] = 0.0f; rig.sample.accel_g[2] = -1.0f; rig.sample.gyro_dps[0] = -0.25f; rig.sample.gyro_dps[1] = 0.1f; rig.sample.gyro_dps[2] = -0.35f; rig.fresh(); expect(rig.motion.ready() && close(rotate(rig.packet().q, {0.0, 0.0, -1.0}), {0.0, 0.0, 1.0}), "a fresh connection must initialize from its real pose without waiting"); for (unsigned i = 0; i < 400; ++i) rig.fresh(10000); expect(close({rig.motion.bias()[0], rig.motion.bias()[1], rig.motion.bias()[2]}, {-0.25, 0.1, -0.35}, 0.000001), "background recovery must learn the new source, not resume a retired target"); } } void stale_boundaries_and_hidden_gaps() { for (bool stale_gyro : {false, true}) { Rig rig; rig.settle(); rig.fresh(50000, stale_gyro, !stale_gyro); rig.fresh(50000, stale_gyro, !stale_gyro); rig.fresh(49999, stale_gyro, !stale_gyro); expect(rig.motion.ready(), "a held sensor remains usable strictly below the 150ms age limit"); rig.fresh(1, stale_gyro, !stale_gyro); expect(!rig.motion.ready(), "either sensor reaching the 150ms limit must invalidate motion"); rig.settle(); } Rig hidden; hidden.settle(); hidden.sample.gyro_dps[2] += 90.0f; hidden.fresh(0); hidden.fresh(50000, false, false); hidden.fresh(50000, false, false); expect(hidden.motion.ready(), "a 50ms update gap is still within the integration limit"); hidden.fresh(50000); expect(hidden.motion.ready(), "a replacement exactly at expiry must preserve the continuously covered interval"); hidden.fresh(50000, false, false); hidden.fresh(50000, false, false); hidden.fresh(49999, false, false); hidden.fresh(2); expect(hidden.motion.ready() && close(rotate(hidden.packet().q, {1.0, 0.0, 0.0}), {1.0, 0.0, 0.0}), "fresh replacements after a hidden gap must reinitialize, not integrate stale angular displacement"); hidden.rest(); hidden.fresh(0); hidden.settle(); expect(close(rotate(hidden.packet().q, {1.0, 0.0, 0.0}), {1.0, 0.0, 0.0}), "stale angular displacement must not replay after recalibration"); } void clocks_sequences_and_connections_wrap() { Rig rig(UINT32_MAX - 2200000u); rig.sample.accel_sequence = rig.sample.gyro_sequence = UINT32_MAX - 30u; rig.settle(); rig.sample.gyro_dps[2] += 90.0f; rig.fresh(0); for (unsigned i = 0; i < 80; ++i) rig.fresh(12500); expect(close(rotate(rig.packet().q, {1.0, 0.0, 0.0}), {0.0, 1.0, 0.0}), "microsecond rollover must preserve one second of physical angular displacement"); ++rig.generation; rig.sample.accel_sequence = rig.sample.gyro_sequence = 0; rig.rest(); rig.fresh(0); expect(rig.motion.ready(), "a new connection must start immediately from its own fresh samples"); rig.settle(); expect(close(rotate(rig.packet().q, {1.0, 0.0, 0.0}), {1.0, 0.0, 0.0}), "a new connection must establish its own reference orientation"); Rig calibration_wrap(UINT32_MAX - 750000u); calibration_wrap.settle(); expect(calibration_wrap.motion.ready(), "the stationary window must span microsecond rollover safely"); } void fresh_gravity_bounds_warm_gyro_tilt_drift() { Rig normal, fast; for (Rig* rig : {&normal, &fast}) { rig->sample.accel_g[0] = 1.0f; rig->sample.accel_g[2] = 0.0f; rig->settle(); // The console trace drifted roughly 0.6 degrees/s after startup. rig->sample.gyro_dps[1] += 0.6f; rig->fresh(0); } double maximum_tilt = 0.0; for (unsigned millisecond = 1; millisecond <= 60000; ++millisecond) { fast.fresh(1000, millisecond % 10 == 0, millisecond % 10 == 0); if (millisecond % 10 == 0) normal.fresh(10000); if (millisecond % 1000 == 0) { const Vector gravity = rotate(normal.packet().q, {1.0, 0.0, 0.0}); const double tilt = std::atan2(std::hypot(gravity[0], gravity[1]), gravity[2]); if (tilt > maximum_tilt) maximum_tilt = tilt; } } expect(maximum_tilt < std::acos(-1.0) / 180.0, "fresh stationary gravity must keep warming gyro tilt drift below one degree"); expect(close(rotate(normal.packet().q, {1.0, 0.0, 0.0}), rotate(fast.packet().q, {1.0, 0.0, 0.0}), 0.0002), "duplicate accelerometer polling must not strengthen gravity correction"); } void dynamic_acceleration_does_not_steer_orientation() { Rig rig; rig.settle(); rig.sample.gyro_dps[2] += 60.0f; rig.sample.accel_g[0] = 2.0f; rig.fresh(0); for (unsigned i = 0; i < 100; ++i) rig.fresh(10000); const auto packet = rig.packet(); expect(close(rotate(packet.q, {1.0, 0.0, 0.0}), {0.5, std::sqrt(0.75), 0.0}), "non-gravity acceleration must not steer gyro orientation"); expect(close(packet.acceleration, {2.0, 0.0, 1.0}), "gravity correction must not replace the consumer's dynamic acceleration"); } void optical_heading_bounds_drift_without_polling_gain() { Rig slow, frequent, gyro_only; for (Rig* rig : {&slow, &frequent, &gyro_only}) { rig->settle(); rig->sample.gyro_dps[2] += 0.6f; rig->fresh(0); } const double initial = camera_heading(slow.motion); slow.motion.observe_optical_heading(slow.now, 1, 0, slow.now, 0.2f, true); frequent.motion.observe_optical_heading(frequent.now, 1, 0, frequent.now, 0.2f, true); double maximum_drift = 0.0; for (uint32_t step = 1; step <= 6000; ++step) { slow.fresh(10000); frequent.fresh(10000); gyro_only.fresh(10000); // The same physical reference at 10Hz and 50Hz, with extra polling of // duplicate 50Hz reports, must give essentially the same drift bound. if (step % 10 == 0) { slow.motion.observe_optical_heading(slow.now, 1, step / 10, slow.now, 0.2f, true); } frequent.motion.observe_optical_heading(frequent.now, 1, step / 2, frequent.now, 0.2f, true); if (step % 10 == 0) { const double drift = std::abs(heading_change(slow.motion, initial)); if (drift > maximum_drift) maximum_drift = drift; } } expect(maximum_drift < 1.3 * std::acos(-1.0) / 180.0, "stable optical bearing must bound a minute of warming gyro yaw drift"); expect(std::abs(heading_change(slow.motion, camera_heading(frequent.motion))) < 0.0002, "optical correction strength must follow sample time, not sample or polling count"); expect(std::abs(heading_change(gyro_only.motion, initial) - 36.0 * std::acos(-1.0) / 180.0) < 0.0002, "without optical observations gravity must leave yaw gyro-derived"); } void optical_anchor_preserves_pose_and_corrects_only_world_yaw() { Rig rig; rig.sample.accel_g[0] = 0.3f; rig.sample.accel_g[1] = 0.4f; rig.sample.accel_g[2] = std::sqrt(0.75f); rig.settle(); // Establish arbitrary nonzero world yaw while preserving this physical tilt. for (unsigned i = 0; i < 3; ++i) rig.sample.gyro_dps[i] += 60.0f * rig.sample.accel_g[i]; rig.fresh(0); for (unsigned i = 0; i < 20; ++i) rig.fresh(25000); rig.rest(); rig.fresh(0); const Quaternion initial = normalized(rig.motion.quaternion()); const double initial_heading = camera_heading(rig.motion); rig.motion.observe_optical_heading(rig.now, 1, 1, rig.now, 0.45f, true); expect(normalized(rig.motion.quaternion()) == initial, "the first optical sample must preserve arbitrary existing yaw and tilt exactly"); rig.sample.accel_g[0] = 1.8f; rig.sample.accel_g[1] = -0.2f; rig.sample.accel_g[2] = 0.6f; rig.fresh(0); for (uint32_t step = 1; step <= 400; ++step) { rig.fresh(25000); if (step % 4 == 0) { rig.motion.observe_optical_heading(rig.now, 1, step / 4 + 1, rig.now, 0.8f, true); } } expect(std::abs(heading_change(rig.motion, initial_heading) + 0.35) < 0.004, "increasing aim-right optical yaw must converge to negative world yaw, not reanchor away motion"); const Quaternion corrected = normalized(rig.motion.quaternion()); for (unsigned axis = 0; axis < 3; ++axis) { Vector basis{}; basis[axis] = 1.0; expect(std::abs(rotate(initial, basis)[2] - rotate(corrected, basis)[2]) < 0.000002, "optical correction must preserve every body axis's gravity-aligned tilt"); } expect(close(rig.packet().acceleration, {1.8, -0.2, 0.6}, 0.0000001), "optical correction must retain real dynamic acceleration in the native packet"); expect(close({rig.motion.bias()[0], rig.motion.bias()[1], rig.motion.bias()[2]}, {Rig::residual[0], Rig::residual[1], Rig::residual[2]}, 0.000001), "optical correction must not modify the calibrated raw gyro bias"); } void optical_duplicates_and_hidden_intervals_do_not_gain_weight() { Rig rig; rig.settle(); const double initial_heading = camera_heading(rig.motion); uint32_t sequence = 10; rig.motion.observe_optical_heading(rig.now, 1, sequence, rig.now, 0.0f, true); rig.sample.gyro_dps[2] += 30.0f; rig.fresh(0); for (unsigned i = 0; i < 40; ++i) { rig.fresh(25000); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, 0.0f, false); } rig.rest(); rig.fresh(0); expect(std::abs(heading_change(rig.motion, initial_heading) - std::acos(-1.0) / 6.0) < 0.00002, "hidden or inferred optical reports must not invent a heading correction"); const double hidden_heading = camera_heading(rig.motion); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, 0.0f, true); expect(std::abs(heading_change(rig.motion, hidden_heading)) < 0.000001, "reacquisition must not spend correction weight accumulated while hidden"); for (unsigned i = 0; i < 4; ++i) rig.fresh(25000); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, 0.0f, true); expect(heading_change(rig.motion, hidden_heading) < -0.02, "continuous reacquisition must correct toward the retained original relative anchor"); const double corrected_heading = camera_heading(rig.motion); for (unsigned i = 0; i < 200; ++i) { rig.fresh(1000); // A changed value and a newly labelled timestamp are still one packet. rig.motion.observe_optical_heading(rig.now, 1, sequence, rig.now, -1.0f, true); } expect(std::abs(heading_change(rig.motion, corrected_heading)) < 0.000001, "duplicate optical sequence identities cannot strengthen correction or inject changed bearings"); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, 0.0f, true); expect(std::abs(heading_change(rig.motion, corrected_heading)) < 0.000001, "duplicate timestamps cannot keep optical validity alive across a stale interval"); } void optical_rejects_bad_samples_and_clock_order() { Rig rig; rig.settle(); rig.motion.observe_optical_heading(rig.now, 1, 1, rig.now, 0.0f, true); uint32_t sequence = 1; const double initial_heading = camera_heading(rig.motion); // Each rejected identity must remain rejected if its fields are rewritten. for (unsigned fault = 0; fault < 4; ++fault) { for (unsigned i = 0; i < 4; ++i) rig.fresh(25000); const float bearing = fault == 0 ? std::numeric_limits::quiet_NaN() : 1.0f; const uint32_t timestamp = fault == 1 ? rig.now - 150000u : fault == 2 ? rig.now + 1u : rig.now; rig.motion.observe_optical_heading(rig.now, 1, ++sequence, timestamp, bearing, fault != 3); for (unsigned i = 0; i < 4; ++i) { rig.fresh(25000); rig.motion.observe_optical_heading(rig.now, 1, sequence, rig.now, 1.0f, true); } expect(std::abs(heading_change(rig.motion, initial_heading)) < 0.000001, "nonfinite, stale, future or unavailable optical identities cannot later be relabelled valid"); } rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, 0.0f, true); const uint32_t last_timestamp = rig.now; rig.fresh(25000); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, last_timestamp - 1u, 1.0f, true); rig.motion.observe_optical_heading(rig.now, 1, sequence - 1u, rig.now, 1.0f, true); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, last_timestamp, 1.0f, true); expect(std::abs(heading_change(rig.motion, initial_heading)) < 0.000001, "backwards clocks, out-of-order sequences and zero-time samples must not correct heading"); // A subsequent ordered sample still contributes its own sensor-time // interval after the rejected observations. rig.fresh(25000); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now - 1u, 1.0f, true); expect(heading_change(rig.motion, initial_heading) < -0.02, "a later fresh unique optical sample must still correct after rejected clock order"); } void optical_generation_and_motion_lifecycle_reanchor() { for (unsigned transition = 0; transition < 4; ++transition) { Rig rig; rig.settle(); rig.motion.observe_optical_heading(rig.now, UINT32_MAX, 100, rig.now, 0.0f, true); for (unsigned i = 0; i < 4; ++i) rig.fresh(25000); rig.motion.observe_optical_heading(rig.now, UINT32_MAX, 101, rig.now, 1.0f, true); expect(heading_change(rig.motion, std::acos(-1.0) / 2.0) < -0.04, "the old optical reference must be active before lifecycle replacement"); uint32_t optical_generation = UINT32_MAX; uint32_t sequence = 102; if (transition == 0) { optical_generation = 0; sequence = 0; } else { if (transition == 1) { rig.sample.gyro_valid = false; rig.fresh(0); rig.sample.gyro_valid = true; } if (transition == 2) rig.motion.reset(); if (transition == 3) ++rig.generation; rig.settle(); } const Quaternion before = normalized(rig.motion.quaternion()); const double heading = camera_heading(rig.motion); rig.motion.observe_optical_heading(rig.now, optical_generation, sequence, rig.now, -0.8f, true); expect(normalized(rig.motion.quaternion()) == before, "optical generation, motion invalidation, reset and reconnect must anchor without an initial jump"); for (unsigned i = 0; i < 40; ++i) { rig.fresh(25000); if (i % 4 == 3) { rig.motion.observe_optical_heading(rig.now, optical_generation, ++sequence, rig.now, -0.8f, true); } } expect(std::abs(heading_change(rig.motion, heading)) < 0.000002, "a replaced optical reference must not pull toward the preceding lifecycle's bearing"); } } void optical_angles_and_sample_clocks_wrap() { Rig rig(UINT32_MAX - 1750000u); rig.settle(); const float pi = std::acos(-1.0f); const float initial_bearing = pi - 0.02f; const double initial_heading = camera_heading(rig.motion); uint32_t sequence = UINT32_MAX - 1u; rig.motion.observe_optical_heading(rig.now, 1, sequence, rig.now, initial_bearing, true); // Matching physical yaw and optical motion cross both wrap boundaries. rig.sample.gyro_dps[2] -= 30.0f; rig.fresh(0); for (unsigned step = 1; step <= 20; ++step) { rig.fresh(25000); if (step % 4 == 0) { const float bearing = std::remainder(initial_bearing + step * 0.025f * pi / 6.0f, 2.0f * pi); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, bearing, true); } } expect(std::abs(heading_change(rig.motion, initial_heading) + std::acos(-1.0) / 12.0) < 0.00002, "optical angle, sequence and microsecond wrap must preserve intentional yaw direction without a jump"); rig.rest(); rig.fresh(0); const float final_bearing = std::remainder(initial_bearing + pi / 12.0f + 0.1f, 2.0f * pi); for (unsigned step = 1; step <= 400; ++step) { rig.fresh(25000); if (step % 4 == 0) { rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, final_bearing, true); } } expect(std::abs(heading_change(rig.motion, initial_heading) + std::acos(-1.0) / 12.0 + 0.1) < 0.001, "wrapped optical observations must continue correcting toward the original relative anchor"); } void optical_vertical_forward_defers_the_anchor() { Rig rig; rig.sample.accel_g[1] = 1.0f; rig.sample.accel_g[2] = 0.0f; rig.settle(); const Quaternion vertical = normalized(rig.motion.quaternion()); uint32_t sequence = 0; for (unsigned i = 0; i < 20; ++i) { rig.fresh(25000); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, i * 0.1f, true); } expect(close(rotate(normalized(rig.motion.quaternion()), {1.0, 0.0, 0.0}), rotate(vertical, {1.0, 0.0, 0.0})), "near-vertical camera forward must reject ill-conditioned optical yaw"); // Tilt smoothly away from vertical with matching physical gravity. rig.sample.gyro_dps[0] -= 90.0f; rig.fresh(0); for (unsigned i = 1; i <= 40; ++i) { const float angle = i * std::acos(-1.0f) / 80.0f; rig.sample.accel_g[1] = std::cos(angle); rig.sample.accel_g[2] = std::sin(angle); rig.fresh(25000); } rig.rest(); rig.fresh(0); const Quaternion horizontal = normalized(rig.motion.quaternion()); const double heading = camera_heading(rig.motion); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, -0.6f, true); expect(normalized(rig.motion.quaternion()) == horizontal, "the first well-conditioned optical observation must establish the anchor without jumping"); for (unsigned i = 0; i < 4; ++i) rig.fresh(25000); rig.motion.observe_optical_heading(rig.now, 1, ++sequence, rig.now, -0.3f, true); expect(heading_change(rig.motion, heading) < -0.01, "heading correction must recover after the forward projection leaves vertical"); } void background_tracking_preserves_motion_and_sensor_time() { Rig turn(0, ProbeNativeMotionBias::kTrackStationary); turn.sample.gyro_dps[2] = 90.0f; turn.fresh(0); for (unsigned i = 1; i <= 1000; ++i) { turn.fresh(10000); expect(turn.motion.ready(), "continuous movement must never block Wii motion"); if (i == 100) expect(close(rotate(turn.packet().q, {1.0, 0.0, 0.0}), {0.0, 1.0, 0.0}), "startup rotation must reach native output immediately"); } expect(turn.motion.bias()[2] == 0.0f, "large steady yaw must not be learned as a stationary offset"); Rig sparse(0, ProbeNativeMotionBias::kTrackStationary); Rig frequent(0, ProbeNativeMotionBias::kTrackStationary); sparse.sample.gyro_dps[2] = frequent.sample.gyro_dps[2] = 1.0f; sparse.fresh(0); frequent.fresh(0); for (unsigned ms = 1; ms <= 4000; ++ms) { frequent.fresh(1000, ms % 10 == 0, ms % 10 == 0); if (ms % 10 == 0) sparse.fresh(10000); } expect(sparse.motion.bias()[2] == 1.0f && frequent.motion.bias()[2] == 1.0f, "stationary gyro offset must converge under either polling cadence"); expect(std::abs(heading_change(sparse.motion, camera_heading(frequent.motion))) < 0.000001, "duplicate polling must not increase background correction or change accumulated yaw"); const double corrected_heading = camera_heading(sparse.motion); for (unsigned i = 0; i < 100; ++i) sparse.fresh(10000); expect(std::abs(heading_change(sparse.motion, corrected_heading)) < 0.000001, "learned correction must stop further stationary yaw drift without recentering"); } } // namespace int main() { codec_wire_edges(); codec_rejects_invalid_inputs(); background_bias_requires_quiet_samples_not_startup_delay(); background_tracking_preserves_motion_and_sensor_time(); quantized_wii_bias_calibrates_and_integrates(); measured_gravity_sets_reference(); bias_corrected_body_rotation_reaches_wire(); elapsed_time_not_packet_count_controls_rotation(); moving_startup_does_not_calibrate(); duplicate_sequences_and_interleaved_recovery(); lifecycle_invalidates_bias_and_orientation(); stale_boundaries_and_hidden_gaps(); clocks_sequences_and_connections_wrap(); fresh_gravity_bounds_warm_gyro_tilt_drift(); dynamic_acceleration_does_not_steer_orientation(); optical_heading_bounds_drift_without_polling_gain(); optical_anchor_preserves_pose_and_corrects_only_world_yaw(); optical_duplicates_and_hidden_intervals_do_not_gain_weight(); optical_rejects_bad_samples_and_clock_order(); optical_generation_and_motion_lifecycle_reanchor(); optical_angles_and_sample_clocks_wrap(); optical_vertical_forward_defers_the_anchor(); return failures ? 1 : 0; }