Сколько на самом деле проехала машина: четыре источника пробега, которые не сходятся

Владелец открыл диалог напоминаний о техобслуживании и увидел строку «при 3000.4 км · осталось 2258 км». На приборной панели той же машины было больше пятидесяти тысяч километров. Ошибки на экране не было, ни красного текста, ни предупреждения: сервис спокойно сообщал, что до замены масла ещё две с лишним тысячи километров, и был при этом внутренне последователен.
Так мы и нашли то, что искали бы иначе очень долго. Пробег в сервисе всплывает как минимум в шести местах: карточка устройства, бот, отчёты, расчёт расхода топлива, прогноз следующей заправки, напоминания о ТО. Каждое место может посчитать пробег по-своему, и раньше считало. Ниже разбор четырёх источников пробега по одному автомобилю, того, чем плох каждый, и правила, к которому мы в итоге пришли. Все числа приведены с указанием, откуда они: измерены по недельной выгрузке боевой базы за 30 августа - 6 сентября 2026 года, взяты из кода или сняты с живой строки в базе.
как устроен наш GPS-мониторинг
Коротко о главном
- По одной и той же машине в один и тот же момент база показывала 50 617 км (одометр прибора) и 742,59 км (накопительный счётчик сервера). Оба числа верны, они просто считают разное.
- Худшая из ошибок оказалась не в формуле, а в типе колонки. Поле ручного ввода объявлено
NOT NULL DEFAULT 0, поэтому «пробег не вводили» хранится как «пробег равен нулю». Напоминание о ТО при этом создаётся, выглядит нормально и не срабатывает никогда: ни ошибки, ни записи в логе.- Назад едут оба машинных источника, но по-разному. У одометра прибора откаты настоящие: 37 раз за неделю, крупнейший на 30 км, 268 записей из 16 641 ниже ранее достигнутого максимума. У серверного счётчика откаты мнимые: 147 падений за неделю, а вычитания в коде нет вовсе, значение проседает из-за записей, пришедших с опозданием. Брать последнее значение нельзя ни у того, ни у другого.
- Накопительный счётчик копит фантомы. В ночь после провала GPS приёмник выдал 14 скачков суммарно на 1029 км, крупнейший 192,4 км за одну секунду, это 692 600 км/ч. За те сутки счётчик прибавил 648 км при 41 км по одометру, и для напоминания с интервалом 3000 км это пятая часть интервала.
- Одни и те же сутки дают 648 км или 812 км в зависимости от определения суточного прироста. Сумма по первому определению сходится с недельной дельтой счётчика, по второму завышена на 164 км.
- Ручной ввод без валидации даёт живые строки вида
odometer_km = 13 975 678.44. Похоже, метры внесли как километры.- Рабочее правило: реальный одометр прибора, если он есть; иначе ручная база плюс прирост по GPS; иначе база как есть. Плюс
Math.max(0, …), чтобы сброс счётчика не уменьшал пробег.
Четыре источника, и каждый плох по-своему
Начнём с самого неприятного факта: четыре числа, снятые с одной машины в одну секунду, отличаются в разы, и ни одно из них не является багом. Вот они, все сняты с боевой базы в один момент времени (15:57:51):
| Источник | Значение | Что это на самом деле |
|---|---|---|
Атрибут позиции odometer |
50 617 км | одометр самого прибора |
Атрибут позиции totalDistance |
742,59 км | счётчик сервера, накопленный по GPS |
Колонка devices.odometer_km |
50 614 км | введено человеком с приборной панели |
| Посуточные агрегаты отчётов | своя шкала на каждый день | min/max атрибута за локальные сутки |
Дальше по каждому: что он измеряет и почему из него нельзя просто взять последнее значение.
Одометр прибора: приходит не в каждом пакете
Атрибут odometer это показание самого прибора, и по смыслу оно ближе всего к тому, что владелец видит на панели. Взято из кода: приходит он в координатных пакетах, в метрах, с шагом обновления 1 км.
Проблем у него три. Первая: он приходит не в каждом пакете. Сенсорные пакеты, те, что несут аналоговые и дискретные входы, одометра не содержат вовсе, а идут они вперемешку с координатными. Взять «последнюю позицию устройства» и прочитать из неё одометр означает с шансом больше половины получить пустоту. Именно из-за этого одометр на карточке устройства когда-то мигал: то есть, то нет.
Вторая: он не монотонен, о чём ниже отдельный раздел с числами. Третья: его шлют не все приборы. Значительная часть трекеров такого атрибута не передаёт в принципе, и для них весь этот источник просто отсутствует.
Счётчик сервера totalDistance: считает с нуля от своего запуска
Второй источник считает не прибор, а сервер: расстояние между соседними принятыми точками, в метрах, нарастающим итогом. Взято из кода Traccar, это его штатное поведение.
Ключевое свойство, из-за которого 742 км стоят рядом с 50 617: счётчик считает с нуля от того момента, когда сервер начал считать. У нас точкой отсчёта стал переезд на новую базу 22 августа 2026 года. То есть 742,59 км это честно пройденное расстояние с момента переезда, а не пробег машины. Отсюда же следует, что счётчик сбрасывается: один сброс уже случился, и ничто не мешает случиться второму.
Третье свойство хуже двух первых: счётчик копит фантомные километры после провалов приёма. Это отдельный раздел ниже.
Ручная база: наследует чужие болезни и вводится руками
Третий источник это то, что человек ввёл сам. Схема хранит две величины, вот они из кода (packages/db/src/schema.ts):
odometerKm: double('odometer_km').notNull().default(0),
odometerAtDistanceM: double('odometer_at_distance_m'),
Смысл пары такой: человек вводит показание с приборной панели, мы запоминаем, на каком значении totalDistance это было, и дальше растим базу приростом GPS. Текущий пробег считается на лету: база плюс разница счётчиков, делённая на тысячу. У владельца из завязки в базе лежало odometer_km = 50614 при odometer_at_distance_m = 738323,799, обе величины сняты с боевой базы.
Схема разумная, но она наследует все болезни totalDistance: фантомные километры попадают в прирост и, значит, в результат. Плюс собственная болезнь, которой нет ни у одного машинного источника: значение вводится руками. И третья, самая тихая, спрятана в первой строке кода выше.
Посуточные агрегаты: отдельная шкала для отчётов
Четвёртый источник живёт в отчётах и в расчёте топлива. Взято из кода (apps/web/src/server/fuel.ts, readDailyDistance): для каждых локальных суток берётся минимум и максимум атрибута прямо в SQL, по два синтетических сэмпла на день.
Здесь есть деталь, которая спасла отчёты от фантомов. Агрегат берёт предпочтительно odometer, а totalDistance использует только как запасной вариант на те сутки, когда одометр молчал. Поэтому у приборов со своим одометром фантомные километры в отчёты не попадают, хотя в счётчике они лежат.
Вторая деталь объясняет, почему агрегат считается в SQL, а не разбором сырых позиций в коде. Трекер шлёт около семи тысяч позиций в сутки, и выборка «последние N позиций» захлёбывалась за один-два дня: старые дни оставались без сэмплов и показывали ноль километров при реально сожжённом топливе.
как читать отчёты по автомобилю
Ноль вместо «не знаю»: ошибка в типе колонки, а не в формуле
Самая дорогая ошибка этого разбора не имеет отношения к арифметике. Она целиком лежит в одном определении колонки: odometer_km объявлен NOT NULL DEFAULT 0.
Читается это так. У устройства, которому пробег никто никогда не вводил, в поле лежит не пустота, а ноль. Разница между «пробега нет» и «пробег равен нулю» на уровне базы стёрта, и восстановить её потом нечем: обе ситуации выглядят одинаково.
Дальше ноль спокойно проходит через всю цепочку. Функция разрешения источника в последней строке возвращает базу как есть, и для такого устройства она честно возвращает валидное число. Ноль километров. Не null, не исключение, не пустое поле в интерфейсе. Число, которое неотличимо от настоящего пробега машины, только что съехавшей с конвейера.
Теперь посмотрим, что из этого получается для напоминания «через 10 000 км заменить масло»:
- цель считается как «текущий пробег плюс интервал», то есть 0 + 10 000 = 10 000 км;
- текущий пробег при каждой проверке снова равен нулю;
- условие «текущий пробег больше либо равен цели» не выполнится никогда.
Напоминание создаётся. Оно появляется в списке. Оно выглядит совершенно нормально, с интервалом, с целью, с оставшимся расстоянием. И оно молчит вечно. Ни ошибки при создании, ни строчки в логе, ни единого внешнего признака того, что функция сломана. Владелец узнает об этом в тот день, когда масло надо было поменять пять тысяч километров назад.
Вот главная мысль всего разбора, ради которой стоило его писать: отсутствие данных, записанное как ноль, опаснее отсутствия данных. Пустое значение шумит. Оно ломает арифметику, роняет проверку, заставляет написать ветку «а что если тут пусто», и в этой ветке кто-то напишет честное «пробег неизвестен, введите показание». Ноль не шумит. Ноль проходит все проверки на валидность, потому что он валиден: это число, оно в допустимом диапазоне, оно не отрицательное, у него правильный тип. Любой контроль, который спрашивает «данные корректны?», ответит «да».
Насколько это не гипотетика, видно по боевой базе. Там нашлось устройство с odometer_km = 0 и без привязки к счётчику, и ещё три устройства с введённой базой, но без odometer_at_distance_m: значения 23 546, 22,38 и 15. Все четыре числа сняты с прода. У трёх последних база введена, но ни к чему не привязана, поэтому она не растёт вообще: пробег заморожен на моменте ввода и будет стоять так, пока кто-нибудь не введёт заново. Значение 22,38 при этом выглядит как чужая единица измерения, а 15 не выглядит вообще никак.
Закрыли это не заменой нуля на null в схеме, а требованием провенанса. Взято из кода (packages/db/src/odometer.ts): источником пробега считается либо реальный одометр прибора, либо база, привязанная к GPS-счётчику. Нет ни того, ни другого, возвращается честный null:
const hasSource = inputs.realOdometerKm != null || base.baseAtDistanceM != null;
if (!hasSource) return null;
Создать напоминание по пробегу для такого устройства теперь нельзя, и диалог объясняет, что нужно сделать, вместо бесполезного «попробуйте ещё раз». Молчащее напоминание заменилось на честный отказ, и это единственный размен, который здесь возможен.
Переносимый вывод: NOT NULL DEFAULT 0 в колонке-измерении стирает разницу между «ноль» и «неизвестно». Для счётчика событий это обычно безобидно, ноль срабатываний и есть ноль. Для показания прибора это ловушка, потому что ноль там означает не измерение, а его отсутствие, и никакая последующая проверка отличить одно от другого уже не сможет.
Одометр прибора едет назад: 37 откатов за неделю
Второй источник ломается тише, но заметнее для владельца: показание прибора не всегда растёт. Вот что измерено по недельной выгрузке одного устройства:
- 37 откатов назад, суммарно на 112 км;
- крупнейшие откаты: 30, 13, 7, 6 и 5 км, средний около 3 км;
- 268 записей из 16 641, то есть 1,6 процента, оказались ниже ранее достигнутого максимума;
- выбросов вверх за ту же неделю ноль.
Что увидел бы владелец, если брать просто свежее значение: пробег на карточке иногда уезжает назад на десятки километров, а строка «до ТО осталось» сама собой растёт. Выглядит это как ошибка сервиса, хотя ошибается прибор.
Лечится это сменой правила чтения. Взято из кода: мы берём максимум по окну последних позиций, а не последнее значение, окно составляет 200 строк с непустым атрибутом. То же самое правило применено на карточке устройства (readOdometers в apps/web/src/server/devices.ts), потому что иначе карточка и напоминания расходились бы ровно на время отката, и это выглядело бы ещё хуже, чем один неверный источник.
У приёма есть честная обратная сторона, и её стоит назвать вслух. Максимум по окну устойчив к откатам вниз, но одиночный завышенный выброс он, наоборот, законсервирует: неверно большое значение продержится, пока не уйдёт из окна. На наших данных шум только вниз, ноль выбросов вверх за неделю, поэтому размен в нашу пользу. Но это свойство конкретных данных конкретного прибора, а не общее правило, и на другом устройстве проверять его придётся заново.
Серверный счётчик тоже едет назад, но его откаты мнимые
По той же недельной выгрузке totalDistance уменьшался 147 раз, крупнейшее падение составило 191,93 км, и случилось оно 1 сентября в 04:51:01. Важно сразу развести это с откатами прибора. У прибора откаты настоящие: показание действительно становится меньше, чем было, и это его собственный шум. У счётчика откатов в буквальном смысле не бывает вовсе, потому что вычитания в коде нет.
Счётчик наращивается только сложением, totalDistance плюс геометрическое расстояние, которое неотрицательно по определению. Но каждая запись считается от той позиции, которую сервер считает последней, а позиция, пришедшая с опозданием, последней не становится: в базу она попадает наравне со всеми, потому что запись в БД стоит в конвейере раньше проверки, а вот базой для следующих не служит. Её раздутый счётчик остаётся тупиковой веткой. Поэтому в порядке приёма значение может уменьшиться, хотя никто ничего не вычитал.
Вот те самые четыре строки вокруг крупнейшего падения:
id servertime fixtime distance total
5524 04:50:36 04:38:58 0 644,46
5525 04:51:00 04:32:06 192,6 837,06 фикс на 18 минут старше предыдущего
5526 04:51:01 04:50:49 0,66 645,13 посчитано от 644,46, а не от 837,06
Запись 5525 пришла из прошлого: её время фикса старше того, что сервер считал последним. Она сохранена, свой раздутый счётчик получила, но следующая запись считалась от прежней базы 644,46. В коде за это отвечают две вещи, и обе стоит увидеть рядом:
// PostProcessHandler.onPosition, уже ПОСЛЕ записи в базу
if (PositionUtil.isLatest(cacheManager, position)) {
cacheManager.updatePosition(position);
}
// PositionUtil.isLatest
return lastPosition == null
|| position.getFixTime().compareTo(lastPosition.getFixTime()) >= 0;
Проверка «эта позиция свежее прежней» стоит в конвейере после сохранения, поэтому в базу опоздавшая запись попадает, а в кэш последней позиции нет. Измерение подтвердило это без исключений: механизм проверяли на недельной выгрузке, и во всех 142 падениях предыдущей строкой оказалась запись с более старым временем фикса. Ещё пять падений насчитались позже, прямым запросом к базе, куда добавились свежие записи, — их отдельно не разбирали.
Откуда берутся сами опоздавшие записи, тоже измерено: их 590 из 41 539 за неделю, медианное отставание 3 минуты, максимальное 936 минут, то есть больше пятнадцати часов. Это буферизация в трекере. Прибор копит телеметрию, пока связи нет, а когда она появляется, досылает накопленное, и оно ложится в базу задним числом. То есть ночные провалы приёма отзываются в счётчике дважды: сначала фантомными километрами по невалидным координатам, потом пачкой записей из прошлого.
почему трекер молчит и что происходит после восстановления связи
Практический итог по обоим источникам одинаковый, хотя причины разные: правило «взять последнее значение» не работает ни там, ни там, и считать, что однажды выросшее число больше не упадёт, нельзя ни по прибору, ни по счётчику.
Одна ночь добавила счётчику шестьсот лишних километров
Третий источник ломается громче всех. Вот суточный прирост по одной машине за неделю, все колонки измерены по одной и той же выгрузке. Для счётчика приведены два способа счёта, и на этом стоит остановиться отдельно: «нетто» это последнее значение суток минус первое, «размах» это максимум минус минимум внутри суток.
| Дата | odometer, км |
totalDistance нетто, км |
totalDistance размах, км |
|---|---|---|---|
| 30.08 | 0,0 | 5,9 | 5,9 |
| 31.08 | 16,0 | 19,1 | 19,1 |
| 01.09 | 41,0 | 648,3 | 812,1 |
| 02.09 | 10,0 | 13,6 | 14,0 |
| 03.09 | 37,0 | 37,8 | 37,8 |
| 04.09 | 12,0 | 11,6 | 11,6 |
| 05.09 | 5,0 | 5,9 | 5,9 |
| 06.09 | 0,0 | 0,3 | 0,3 |
| Сумма | 121,0 | 742,5 | 906,8 |
Первое, что стоит проверить в такой таблице: сходится ли она с независимо измеренной величиной. Недельная дельта счётчика, то есть последнее значение недели минус первое, равна 742,55 км, и колонка «нетто» в сумме даёт ровно её. Колонка «размах» даёт 906,8, на 164 км больше. Лишнее приходит из одиночных всплесков, которые попали в максимум суток, но не остались в значении на конец суток: самый крупный из них поднял счётчик до 837,06 и никуда дальше не распространился. Сверка недельной суммы с недельной дельтой это самая дешёвая проверка любого суточного агрегата, и она сразу показывает, каким определением он посчитан.
Различаются определения ровно в одних сутках, в тех самых, где случились фантомы. В остальные дни обе колонки совпадают до десятых, потому что таких всплесков там не было.
Что было в ту ночь, видно по сырым записям. Сначала провал приёма: с 31 августа 21:28 до 1 сентября 03:53 не пришло ни одного валидного фикса. Утром приёмник поймал спутники и выдал серию невалидных координат. Измерено по выгрузке: 14 скачков между соседними точками длиннее километра каждый, суммарно на 1029 км. Вот четыре из них:
04:24:29 → 04:25:58 246,5 км за 89 с = 9 970 км/ч
04:27:33 → 04:39:29 192,8 км за 716 с = 970 км/ч
04:39:29 → 04:51:00 192,4 км за 691 с = 1 002 км/ч
04:51:00 → 04:51:01 192,4 км за 1 с = 692 600 км/ч
Traccar не сделал ничего неправильного. Он получил две точки, посчитал между ними расстояние и прибавил его к накопительному счётчику. Формула верна, входные данные нет. Скорость, которую в те же секунды рапортовал сам трекер, была нормальной: врали именно координаты, и в этом главная неприятность такого сбоя. Прибор не сообщает «мне плохо», он сообщает координаты, которым сам верит.
Почему это не косметика. У напоминания владельца интервал составлял 3000 километров, это видно по живой строке: цель 3000,4 при счётчике 0,4 на момент создания. За те сутки счётчик прибавил 648 км при реальном 41, то есть около шестисот лишних километров, пятая часть интервала. Для устройства, у которого нет собственного одометра и весь пробег считается сервером, одна ночь без спутников сдвигает замену масла на пятую часть интервала раньше, молча и с виду обоснованно.
Отдельно стоит проговорить, что здесь произошло с самим вопросом. «Сколько машина проехала за сутки» звучит как вопрос с одним ответом, а ответов получилось два: 648 и 812, разница в четверть, и оба определения законны. Первое отвечает «на сколько за сутки изменился счётчик», второе «какой размах счётчик прошёл внутри суток». В обычные дни разницы нет, и заметить подмену не на чем, а в аномальный день она сразу становится крупнее всего остального в таблице. Это уже третий раз в нашей серии разборов, когда определение решает больше, чем данные: в статье про размапливание каналов вопрос «стояла ли машина» дал разбивку 75 на 8 по одному определению и 29 на 54 по другому, на одних и тех же событиях. Закономерность простая: пока данные скучные, определение не видно, а когда данные становятся интересными, оно и оказывается главным.
как определение меняет ответ в семь раз
Механику самого провала мы разбирали отдельно, там же объясняется, почему в такие часы сервис может показывать «машина едет» на полностью неподвижном автомобиле.
почему трекер показывает движение при потерянном GPS
Живая строка: 13 975 678,44 километра
Ручной ввод ломается способом, который невозможно предсказать из кода, зато легко увидеть в базе. На проде лежит устройство с odometer_km = 13 975 678.44. Почти четырнадцать миллионов километров. Это живая строка, а не пример из головы.
Похоже, человек внёс метры в поле, подписанное как километры. Значение при этом абсолютно валидно с точки зрения типа: положительное число с плавающей точкой, ноль проверок нарушено. И оно останется в базе навсегда, потому что нижней или верхней границы у поля нет, а исправлять его некому: владелец увидит на карточке четырнадцать миллионов и решит, что сервис сломан.
Отсюда практический аргумент, который обычно звучит занудно, но здесь подкреплён строкой из базы: у поля-измерения должны быть границы правдоподобия, и проверять их надо на вводе, а не при чтении. Верхняя граница в миллион километров отсекла бы эту строку и стоила бы одной строчки в схеме валидации. После записи данные уже не спасти: отличить «внёс метры» от «купил машину с очень большим пробегом» автоматически нельзя, а спрашивать поздно.
Обратите внимание, что это ровно та же болезнь, что и в разделе про ноль, только с другой стороны диапазона. Там валидное число означало отсутствие измерения, здесь валидное число означает ошибку единицы измерения. В обоих случаях проверка «это корректное число?» отвечает «да» и ничего не ловит.
как ввести и поправить пробег в карточке машины
Что чинится на входе: один фильтр вместо пяти
Фантомные километры дешевле не пускать в базу, чем вычищать потом, и на входе у Traccar для этого есть фильтры. Мы включили ровно один: filter.maxSpeed = 200. Значение задаётся в узлах, это примерно 370 км/ч. Параметр и его поведение проверены по исходникам версии 6.5 и по байткоду боевого образа.
Как он работает, важно для сути. Фильтр считает скорость сам: расстояние между предыдущей точкой и текущей, делённое на время между ними. Ту скорость, которую прислал трекер, он не смотрит вообще. Для нашего случая это принципиально, потому что в ту ночь трекер рапортовал нормальную скорость, а врали координаты. В конвейере обработки расчёт расстояния идёт до фильтра, а запись в базу после, поэтому отфильтрованная позиция в базу не попадает и счётчик не растит. Предыдущая точка при этом берётся из кэша, а кэш обновляется только после сохранения, и благодаря этому серия скачков режется целиком, а не только первый из них.
У фильтра есть цена: отфильтрованная позиция пропадает навсегда, она не помечается, а исчезает. Единственный след это строка уровня INFO в логе сервера о том, что позиция отброшена фильтром максимальной скорости.
Теперь сюжетный поворот, ради которого этот раздел здесь. Остальные «логичные» фильтры пришлось оставить выключенными, потому что каждый из них убил бы наши датчики. Фильтр «отбрасывать сообщения без GPS-локации» отбросил бы ровно сенсорные пакеты, у которых координат и не бывает. Фильтр по нулевой скорости выбросил бы их на стоянке, то есть в большей части времени. Фильтр дубликатов по совпадению времени выбросил бы их же. Фильтр максимальной скорости безопасен именно потому, что смотрит на пройденное расстояние: у сенсорного пакета координаты те же, что у предыдущей позиции, расстояние равно нулю, и под порог он не подпадает никогда.
как размапить каналы датчиков без документации
Правило, к которому пришли
Итоговое правило чтения пробега держится на приоритете источников, а не на попытке их усреднить. Вот оно целиком, взято из кода (packages/contracts/src/odometer.ts):
export function resolveOdometerKm(source: OdometerSource): number | null {
if (source.realOdometerKm != null) return source.realOdometerKm;
if (source.baseKm != null && source.baseAtDistanceM != null && source.totalDistanceM != null) {
return source.baseKm + Math.max(0, (source.totalDistanceM - source.baseAtDistanceM) / 1000);
}
return source.baseKm;
}
Читается сверху вниз. Первый приоритет: реальный одометр прибора, если он есть. Это единственный источник, который измеряет то самое, что видит владелец на панели, и не зависит ни от качества приёма, ни от истории сервера. Значение сюда приходит уже очищенным, максимумом по окну, а не последней записью.
Второй приоритет: ручная база плюс прирост по GPS. Он включается для приборов без собственного одометра и требует обеих величин сразу, базы и точки привязки. Именно здесь работает Math.max(0, …): если totalDistance сбросился, разница станет отрицательной, и без обрезки пробег машины уменьшился бы на несколько сотен километров в один момент. Обрезка означает «при сбросе счётчика пробег стоит на месте, пока прирост не вернётся». Это неточно, но неточно в безопасную сторону, и во всяком случае лучше, чем пробег, который поехал назад.
Обрезка при этом нужна не только на редкий случай сброса. Точка привязки базы может быть снята с записи, которая оказалась тупиковой веткой, то есть с раздутого счётчика опоздавшей позиции. Тогда следующие значения посчитаются от прежней базы и окажутся ниже точки привязки, разница уйдёт в минус безо всякого сброса, и Math.max отработает именно там. За неделю таких проседаний в счётчике было 147, так что случай не теоретический.
Третий приоритет: база как есть. Ровно эта строка и возвращала злополучный ноль, поэтому выше по стеку стоит проверка провенанса из раздела про NOT NULL DEFAULT 0. До неё доходят только устройства, у которых база введена осмысленно.
Отдельным правилом, которое к одометру формально не относится, но ломает его так же надёжно: сортировать позиции надо по серверному времени, а не по времени фикса. У трекера без спутников время фикса стоит на месте, и сортировка по нему выдаёт за свежее то, что пришло раньше. Насколько эти два порядка расходятся, теперь измерено: 590 записей из 41 539 за неделю пришли с опозданием относительно собственного времени фикса, а число падений счётчика меняется с 147 на 167 в зависимости от того, по какому полю сортировать. Этот класс ошибки мы ловили трижды в разных местах: в алертах, в датчиках и здесь.
Как перевели существующее напоминание, не испугав владельца
Смена источника пробега это смена шкалы, и все ранее записанные абсолютные значения после неё означают что-то другое. Напоминание хранит абсолютную цель, посчитанную когда-то как «текущий пробег плюс интервал». Цель 3000,4 была записана в шкале серверного счётчика. В новой шкале, где текущий пробег 50 617, та же цель означала бы, что ТО просрочено на сорок семь тысяч километров, и владелец увидел бы это первым же утром.
Правильный перенос сохраняет не число, а то, что видит человек:
осталось = старая цель − текущий пробег в СТАРОЙ шкале
новая цель = текущий пробег в НОВОЙ шкале + осталось
Обе величины надо снимать в один момент, иначе между двумя чтениями машина проедет и сдвиг уедет вместе с ней. На живом напоминании из завязки это выглядело так: старая шкала 742,6 при цели 3000,4, новая шкала 50 617, осталось 2257,8, новая цель 52 874,8. Число «осталось 2258 км» на экране владельца до и после переключения совпало, и это и было проверкой, что сдвиг верный.
Инструмент для такой правки стоит делать двухшаговым. Наш скрипт (infra/tools/reminder-odometer-shift.mjs) без флага --apply только печатает пересчёт и ничего не меняет. Данные правятся один раз, и посмотреть на пересчёт глазами дешевле, чем потом откатывать.
Почему «взять последнее значение» неверно во всех четырёх случаях
Соберём практический итог. Три раза подряд самый очевидный ответ оказался неверным, и каждый раз по своей причине:
- Взять последнюю позицию устройства. В ней может не быть нужного атрибута вовсе: сенсорные пакеты одометра не несут, и «последняя позиция» с шансом больше половины его не содержит. Надо брать последнюю позицию с непустым атрибутом, а это уже другой запрос.
- Взять последнее показание одометра. Прибор откатывается назад: 37 раз за неделю, до 30 км за раз. Надо брать максимум по окну.
- Взять последнее значение серверного счётчика. В нём уже лежат фантомные километры от ночных провалов приёма: почти вся недельная прибавка пришла из одних суток, 648 км при 41 км по одометру. Плюс 147 проседаний за неделю на записях, пришедших с опозданием, а «последняя строка» вполне может оказаться одной из них. Надо либо предпочитать одометр прибора, либо фильтровать вход.
- Взять то, что ввёл человек. Это либо ноль, означающий «не вводили», либо четырнадцать миллионов, означающие «ошиблись единицей». Надо требовать привязки к счётчику и проверять границы правдоподобия.
Как выбирать источник, если вы делаете похожий сервис. Сначала спросите себя, что именно должно быть измерено: пробег машины, пробег за период или расстояние, пройденное под наблюдением сервера. Это три разные величины, и путаница между ними и порождает большинство расхождений. Для пробега машины годится только одометр прибора или ручная база с приростом. Для расстояния за период годится посуточный агрегат. Серверный счётчик сам по себе не годится ни для чего, что показывается человеку: он отвечает на вопрос «сколько сервер насчитал с момента, который человеку неизвестен».
Дальше три свойства, которые надо закладывать в чтение с самого начала, а не докручивать потом. Первое: у измерения от прибора есть направление, пробег не убывает, и чтение должно это направление уважать, потому что сами источники его не уважают. Второе: у него есть провенанс, то есть ответ на вопрос, кто именно посчитал это число, и провенанс надо хранить рядом со значением. Наша пара «база плюс точка привязки» это и есть провенанс, вынесенный в схему, и именно её отсутствие превратило ноль в валидный пробег. Третье: у агрегата есть определение, и его надо писать рядом с числом, иначе «за сутки проехали 648» и «за сутки проехали 812» невозможно ни сверить, ни воспроизвести.
Верное число и два неверных объяснения к нему
Самый поучительный эпизод разбора мы приберегли к концу, потому что он не про пробег, а про то, как вообще устроены такие разборы. Число «147 падений счётчика за неделю» было посчитано один раз и с тех пор не менялось. Объяснений к нему было три, и первые два оказались неверными.
Версия первая: сервер прибавил фантом и вычел его обратно. Она выглядела убедительно, потому что крупнейшее падение случилось ровно в секунду крупнейшего скачка, 191,93 против 192,4. Красивое совпадение, готовая история: счётчик сам себя чинит. Сняло её чтение исходников. В обработчике расстояния есть только сложение, а прибавляемая величина это геометрическое расстояние между точками, оно неотрицательно по определению. Вычитания в коде нет вовсе, поэтому «вычел обратно» это не то, что могло произойти в принципе.
Версия вторая: это артефакт сортировки. Тоже правдоподобно: если строки читать не в том порядке, в каком они появлялись, монотонная величина легко покажется скачущей. Сняло её измерение. Пересчёт в четырёх порядках дал 147 падений при сортировке по серверному времени, 147 по идентификатору, то есть по порядку вставки, 147 по паре «серверное время плюс идентификатор» и 167 по времени фикса. Падения есть в самом порядке записи строк, значит порядок чтения их не создаёт.
Версия третья, оставшаяся. Записи, пришедшие с опозданием, попадают в базу, но не становятся последней позицией, и следующая запись считается от прежней базы. Её проверяли обоими инструментами сразу: измерением, где во всех 142 разобранных падениях предыдущая строка это запись с более старым временем фикса, и чтением исходников, где проверка «эта позиция свежее» стоит после записи в базу.
Вот вывод, ради которого этот раздел стоит держать в статье про расхождение источников. Измерение может быть верным, а объяснение к нему неверным, и проверяются они разными инструментами. Число проверяется пересчётом: другой порядок, другое определение, другая выборка. Объяснение пересчётом не проверяется вообще, для него нужен либо исходник, либо предсказание, которое можно опровергнуть данными. Мы дважды получали правдоподобный механизм, который согласовывался со всеми известными числами и всё равно был неверен, и оба раза его снял не спор, а обращение к другому инструменту.
Отсюда же практическая привычка: когда объяснение меняется, а число остаётся, это хороший знак, число стоит крепко. Когда наоборот, число подстраивается под объяснение, разбор пора начинать заново.
Чего мы не утверждаем
Что так ведут себя все приборы. Всё измеренное здесь получено на одном устройстве за одну неделю. Другой прибор может не откатываться назад вовсе, может не слать одометр в принципе или слать его в других единицах. Переносить наши 37 откатов на марку, на протокол или на рынок было бы неправильно.
Что максимум по окну это универсально правильный приём. Он выигрывает, потому что на наших данных шум идёт только вниз. Прибор с завышенными выбросами он бы, наоборот, испортил, и лечить такое пришлось бы иначе.
Что фильтр максимальной скорости решает проблему фантомов. Он отсекает телепорты, но не отсекает медленно накапливающуюся ошибку, а также навсегда выбрасывает отфильтрованные позиции. Мы сознательно приняли этот размен и знаем, что у него есть цена.
Что причина ночных скачков установлена. Со стороны сервера видно, что после долгого отсутствия фиксов приёмник выдал невалидные координаты. Почему он это сделал, из данных не следует.
Что буферизация в трекере доказана. Измерено другое: 590 записей за неделю пришли с временем фикса старше уже принятого, медиана отставания 3 минуты, максимум 936 минут. Досылка накопленного после восстановления связи объясняет это лучше всего, но со стороны сервера видно только само отставание, а не то, что происходило в приборе.
Что 648 км «правильнее», чем 812 км. Оба числа посчитаны верно по одним и тем же записям, просто по разным определениям суточного прироста. Мы выбрали нетто, потому что его сумма сходится с недельной дельтой счётчика, и это проверяемый аргумент, а не вкусовой. Для вопроса «какой максимум счётчик показывал внутри суток» правильным будет как раз размах.
Частые вопросы
Почему пробег в сервисе меньше, чем на приборной панели? Скорее всего, вы смотрите на накопительный счётчик сервера, а не на одометр. Счётчик считает с нуля от момента, когда сервер начал получать данные от вашего прибора, поэтому у нас он показывал 742 км при реальных 50 617. Это не ошибка, это другая величина. Лечится вводом текущего показания с панели, после чего сервис растит его приростом.
Почему пробег иногда уменьшается? Потому что назад едет и показание прибора, и счётчик сервера, причём по разным причинам. У прибора это собственный шум показания: 37 откатов за неделю, крупнейший на 30 км. У счётчика вычитания не происходит вовсе, но 147 раз за неделю значение проседало, потому что запись, пришедшая с опозданием, попадает в базу с раздутым счётчиком и не становится базой для следующих. Если сервис показывает последнее пришедшее значение, оба эффекта видны на экране как уменьшение пробега.
Почему за один и тот же день получаются два разных пробега? Скорее всего, вы сравниваете два разных определения суточного прироста. У нас 1 сентября дало 648 км по определению «последнее значение суток минус первое» и 812 км по определению «максимум минус минимум внутри суток». Проверить, какое из них считает ваш отчёт, можно сложением: сумма суточных значений за неделю должна совпасть с разностью между последним и первым значением недели.
Откуда взялись лишние сотни километров за одну ночь? Из невалидных координат после долгого отсутствия спутников. Сервер честно считает расстояние между соседними точками, и если одна из них улетела на двести километров, эти двести километров попадают в счётчик. разбор ночных провалов приёма
Напоминание о ТО создано, но не срабатывает. Что проверить? Первым делом проверьте, знает ли сервис ваш текущий пробег вообще. Если пробег для устройства не определён, а хранится как ноль, цель напоминания оказывается недостижимой, и оно будет молчать без всяких признаков поломки. У нас это чинилось запретом создавать такие напоминания вовсе.
Можно ли просто усреднить несколько источников? Нет, и это худший из возможных вариантов. Источники измеряют разные величины в разных шкалах, поэтому среднее между 50 617 и 742 не означает ничего. Работает только явный приоритет: сначала лучший источник, затем следующий, с честным отказом, если ни одного нет.
Вывод
Четыре числа по одной машине расходились не потому, что где-то ошиблась арифметика. Каждое из них было посчитано верно, и каждое отвечало на свой вопрос, просто вопросы никто не записал рядом с числами. Пробег машины, расстояние с момента запуска сервера, введённое человеком показание и суточный агрегат для отчёта это четыре разные величины, и как только они начинают показываться в одном интерфейсе под одним словом «пробег», расхождение неизбежно.
Самый дорогой из найденных дефектов при этом не имел отношения ни к одному источнику. Он лежал в определении колонки: NOT NULL DEFAULT 0 превратил «мы не знаем пробег» в «пробег равен нулю», и дальше это валидное число прошло сквозь всю систему, не потревожив ни одной проверки. Молчащая функция хуже падающей, потому что падение видно сразу, а молчание обнаруживается через десять тысяч километров.
И последнее, что стоит забрать из этого разбора, даже если ваш сервис не про пробег. Одно и то же число, 147 падений счётчика, пережило два убедительных объяснения, прежде чем нашлось верное. Измерение проверяется пересчётом, объяснение проверяется исходником или предсказанием, которое можно опровергнуть, и подменять один инструмент другим бесполезно: правдоподобный механизм согласуется с данными ровно так же, как настоящий.
Практический следующий шаг, если у вас есть свой поток телеметрии: возьмите любое поле-измерение, в котором может отсутствовать значение, и посмотрите на его определение в схеме. Если там стоит NOT NULL DEFAULT 0, у вас в базе уже лежат нули, которые означают «не измеряли», и отличить их от настоящих нулей больше нечем.
Материал подготовлен на данных сервиса мониторинга «Точка GPS»: выгрузка боевой базы за 30 августа - 6 сентября 2026 года по одному устройству, 41 539 записей, из них 16 641 навигационная, плюс отдельные строки из боевой базы, приведённые в тексте. Суточные приросты пересчитаны перед публикацией в двух определениях и сверены с недельной дельтой счётчика, падения счётчика пересчитаны в четырёх порядках сортировки. Фрагменты кода взяты из рабочего репозитория сервиса и из исходников Traccar 6.5, поведение фильтров дополнительно проверено по байткоду работающего образа.