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 <ladyada@users.noreply.github.com>
This commit is contained in:
ladyadaandLimor Fried committed 2026-09-16 10:11:28 -04:00
1 parent 6cbdadb512
commit 46f0ff40a1
4 files changed
+227 -4

No files matched your search

+8 -4
View File
@@ -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
@@ -0,0 +1,139 @@
#include <Adafruit_GNSS.h>
#include <Arduino.h>
#include <string.h>
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;
}
+75
View File
@@ -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.
+5
View File
@@ -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,