From 46f0ff40a1cfe11e00f77ef071a987bd1a68ffc1 Mon Sep 17 00:00:00 2001 From: ladyada Date: Wed, 16 Sep 2026 10:11:28 -0400 Subject: [PATCH] Preserve GNSS coordinate precision when formatting decimal degrees Format retained coordinate components with integer long division so AVR logging can retain submillimeter detail without a floating-point type override. Add bounded-output and precision regressions. Co-authored-by: Limor Fried --- extras/tests/README.md | 12 +- .../tests/coordinate_text/coordinate_text.ino | 139 ++++++++++++++++++ src/Adafruit_GNSS.cpp | 75 ++++++++++ src/Adafruit_GNSS.h | 5 + 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 extras/tests/coordinate_text/coordinate_text.ino diff --git a/extras/tests/README.md b/extras/tests/README.md index 51763dd..c49a0d2 100644 --- a/extras/tests/README.md +++ b/extras/tests/README.md @@ -32,10 +32,14 @@ The sketches remain usable on Arduino boards with the normal Arduino core. Coordinate precision tests preserve all nine retained fractional-minute digits in the GNSS core and distinguish positions about 0.185 mm apart through the GPS double output, including southern latitude and longitude near 180 degrees west. -Those positions deliberately share the same legacy E7 coordinate. The default -float fields and E7 fields are not full-precision RTK outputs. Future receiver -APIs and logging examples must preserve the core precision through storage, -public results, and formatting without requiring a global float-type override. +The coordinate-text regression also checks integer-only decimal-degree output +with 11 fractional digits, including adjacent retained fractional-minute values +and every output capacity. This output preserves detail on AVR without a global +float-type override. The submillimeter positions deliberately share the same +legacy E7 coordinate. The default float fields and E7 fields are not +full-precision RTK outputs. Future receiver APIs and logging examples must +preserve the core precision through storage, public results, and formatting +without requiring a global float-type override. Physical reception tests live separately in `extras/hw_tests/gnss_receive`. Build and run them explicitly on the documented Nano or HILBERT fixture; the diff --git a/extras/tests/coordinate_text/coordinate_text.ino b/extras/tests/coordinate_text/coordinate_text.ino new file mode 100644 index 0000000..57cb944 --- /dev/null +++ b/extras/tests/coordinate_text/coordinate_text.ino @@ -0,0 +1,139 @@ +#include +#include +#include + +bool testsPassed = false; +bool checkText(const gnss_coordinate_t &coordinate, const char *expected); +bool checkRejected(const gnss_coordinate_t &coordinate); + +void setup() { + Serial.begin(115200); + Serial.println(F("Adafruit GNSS exact coordinate text regression")); + const struct { + const char *field; + const char *hemisphere; + const char *expected; + } cases[] = { + {"4807.038123456", "N", "48.11730205760"}, + {"4807.038123456", "S", "-48.11730205760"}, + {"01131.000", "E", "11.51666666666"}, + {"17959.123456789", "W", "-179.98539094648"}, + {"8959.123456789", "S", "-89.98539094648"}, + {"8959.123456889", "S", "-89.98539094815"}, + {"8959.999999999", "N", "89.99999999998"}, + {"0000.000000001", "N", "0.00000000001"}, + {"0000.000000002", "N", "0.00000000003"}, + {"0000", "N", "0.00000000000"}, + {"0000", "S", "-0.00000000000"}, + {"00000", "W", "-0.00000000000"}, + {"9000", "S", "-90.00000000000"}, + {"18000", "E", "180.00000000000"}, + {"18000", "W", "-180.00000000000"} + }; + for (uint8_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + gnss_coordinate_t coordinate = Adafruit_GNSS::parseCoordinate( + {cases[i].field, strlen(cases[i].field)}, {cases[i].hemisphere, 1}); + if (coordinate.status != NMEA_NUMBER_VALID || + !checkText(coordinate, cases[i].expected)) { + Serial.println(F("FAIL: exact text, truncation, or output buffer bounds")); + return; + } + } + Serial.println(F("PASS: hemispheres, boundaries, and all output capacities")); + Serial.println(F("PASS: submillimeter and adjacent fractional-minute values")); + + gnss_coordinate_t valid = Adafruit_GNSS::parseCoordinate({"4807.038", 8}, {"N", 1}); + for (uint8_t i = 0; i < 7; i++) { + gnss_coordinate_t invalid = valid; + if (i == 0) { + invalid.status = NMEA_NUMBER_EMPTY; + } + if (i == 1) { + invalid.hemisphere = 'X'; + } + if (i == 2) { + invalid.minutes = 60; + } + if (i == 3) { + invalid.fractionalMinutes = 1000000000UL; + } + if (i == 4) { + invalid.degrees = 91; + } + if (i == 5) { + invalid.degrees = 90; // Nonzero minutes at the pole. + } + if (i == 6) { + invalid.hemisphere = 'W'; + invalid.degrees = 180; // Nonzero minutes at the date line. + } + if (!checkRejected(invalid)) { + Serial.println(F("FAIL: invalid coordinate components were formatted")); + return; + } + } + gnss_coordinate_t empty = {}; + if (!checkRejected(empty) || + Adafruit_GNSS::formatCoordinate(NULL, GNSS_COORDINATE_TEXT_SIZE, valid)) { + Serial.println(F("FAIL: empty coordinate or NULL output accepted")); + return; + } + // The E7 convenience field must never be the source of precise text. + valid.degreesE7 = 0; + if (!checkText(valid, "48.11730000000")) { + Serial.println(F("FAIL: output depended on the lower-resolution E7 value")); + return; + } + Serial.println(F("PASS: invalid components rejected; E7 is not used")); + testsPassed = true; +} + +void loop() { + if (testsPassed) { + Serial.println(F("PASS: exact coordinate text regression")); + } else { + Serial.println(F("FAIL: exact coordinate text regression")); + } +} + +bool checkText(const gnss_coordinate_t &coordinate, const char *expected) { + size_t length = strlen(expected); + for (size_t capacity = 0; capacity <= GNSS_COORDINATE_TEXT_SIZE; capacity++) { + char storage[GNSS_COORDINATE_TEXT_SIZE + 2]; + memset(storage, '?', sizeof(storage)); + char *output = storage + 1; + size_t written = Adafruit_GNSS::formatCoordinate(output, capacity, coordinate); + size_t changed = capacity ? 1 : 0; + if (capacity > length) { + if (written != length || strcmp(output, expected)) { + return false; + } + changed = length + 1; + } else if (written || (capacity && output[0])) { + return false; + } + if (storage[0] != '?') { + return false; + } + for (size_t i = changed + 1; i < sizeof(storage); i++) { + if (storage[i] != '?') { + return false; + } + } + } + return true; +} + +bool checkRejected(const gnss_coordinate_t &coordinate) { + char output[GNSS_COORDINATE_TEXT_SIZE]; + memset(output, '?', sizeof(output)); + if (Adafruit_GNSS::formatCoordinate(output, sizeof(output), coordinate) || output[0]) { + return false; + } + for (size_t i = 1; i < sizeof(output); i++) { + if (output[i] != '?') { + return false; + } + } + return true; +} diff --git a/src/Adafruit_GNSS.cpp b/src/Adafruit_GNSS.cpp index fccdac4..861bd9c 100644 --- a/src/Adafruit_GNSS.cpp +++ b/src/Adafruit_GNSS.cpp @@ -100,6 +100,81 @@ gnss_coordinate_t Adafruit_GNSS::parseCoordinate(nmea_span_t coordinate, return result; } +/**************************************************************************/ +/*! + @brief Format exact coordinate components as signed decimal degrees. + @param output Writable text buffer; must not overlap coordinate. + @param capacity Buffer size including the NUL terminator. Use + GNSS_COORDINATE_TEXT_SIZE to fit every valid coordinate. + @param coordinate Validated components from parseCoordinate(). + @return Characters written, excluding NUL, or zero for invalid components + or insufficient storage. On failure output[0] is cleared when possible; + no other output bytes are changed. + + Writes exactly 11 fractional degree digits, truncated toward zero. This + distinguishes every retained fractional-minute increment and introduces + less than 0.00000000001 degree of formatting error (about 1.2 micrometers + of latitude). Southern/western zero retains its minus sign. The components, + not the lower-resolution degreesE7 member, supply the result. + + Integer long division uses at most 32-bit arithmetic, with no heap or + floating-point conversion. AVR output retains the same precision as other + targets, regardless of NMEA_FLOAT_T or the platform's double size. +*/ +/**************************************************************************/ +size_t Adafruit_GNSS::formatCoordinate(char *output, size_t capacity, + const gnss_coordinate_t &coordinate) { + if (!output || !capacity) + return 0; + output[0] = '\0'; + char direction = coordinate.hemisphere; + bool latitude = direction == 'N' || direction == 'S'; + uint16_t limit = latitude ? 90 : 180; + uint16_t degrees = coordinate.degrees; + uint16_t remainder = coordinate.minutes; + uint32_t fraction = coordinate.fractionalMinutes; + if (coordinate.status != NMEA_NUMBER_VALID || + (!latitude && direction != 'E' && direction != 'W') || degrees > limit || + remainder >= 60 || fraction >= 1000000000UL || + (degrees == limit && (remainder || fraction))) + return 0; + bool negative = direction == 'S' || direction == 'W'; + uint8_t digits = 1; + uint16_t divisor = 1; + if (degrees >= 100) { + digits = 3; + divisor = 100; + } else if (degrees >= 10) { + digits = 2; + divisor = 10; + } + // Account for the sign, decimal point, eleven fractional digits, and NUL. + if (capacity < (size_t)(negative + digits + 13)) + return 0; + size_t written = 0; + if (negative) + output[written++] = '-'; + while (divisor) { + output[written++] = '0' + degrees / divisor; + degrees %= divisor; + divisor /= 10; + } + output[written++] = '.'; + uint32_t place = 100000000UL; + for (uint8_t i = 0; i < 11; i++) { + remainder *= 10; + if (place) { + remainder += fraction / place; + fraction %= place; + place /= 10; + } + output[written++] = '0' + remainder / 60; + remainder %= 60; + } + output[written] = '\0'; + return written; +} + /**************************************************************************/ /*! @brief Decode a complete UTC hhmmss[.fraction] field. diff --git a/src/Adafruit_GNSS.h b/src/Adafruit_GNSS.h index 884ee01..1f294e1 100644 --- a/src/Adafruit_GNSS.h +++ b/src/Adafruit_GNSS.h @@ -12,6 +12,9 @@ #include "Adafruit_NMEA.h" +/// Maximum coordinate text size: sign, three degrees, dot, 11 decimals, NUL. +#define GNSS_COORDINATE_TEXT_SIZE 17 + /** Validated coordinate components, with no floating-point conversion. * Every value except status is zero on failure. Southern and western * coordinates have negative degreesE7; the other components are unsigned. @@ -64,6 +67,8 @@ class Adafruit_GNSS { public: static gnss_coordinate_t parseCoordinate(nmea_span_t coordinate, nmea_span_t hemisphere); + static size_t formatCoordinate(char *output, size_t capacity, + const gnss_coordinate_t &coordinate); static gnss_time_t parseTime(nmea_span_t field); static gnss_date_t parseDate(nmea_span_t field); static gnss_validation_t validateNavigation(nmea_span_t type,