Кодекс летописца, или Ода к телеметрии

Допустим, ко группе инженеров снизошла задача разработать систему управления чем-нибудь достаточно сложным. Теоретик заточил зубы и приступил к граниту — строит модели объекта и системы управления. Комплексники копают руду компоновки, вопросов климатики, вибрации и спецтребований, кто-то рисует платы/корпуса/кабели, кто-то пишет и тестирует уже определившиеся элементы ПО.

И здесь же, с самого старта, нужен человек, который займется телеметрией: ее формированием, передачей и сохранением. Ибо переоценить важность телеметрии для разработки практически нереально. Когда что-то пойдет не так — а оно пойдет — только телеметрия даст шанс понять, что это, черт возьми, было. Когда все будет так — она станет объективным доказательством успеха. Больше того: иногда, когда внешне все прошло так, она заставит при анализе запуска уронить челюсть и спросить себя и окружающих: «как, черт возьми, всё обошлось?»

А потому исходное положение кодекса, пункт зеро:

0. Телеметрия нужна

В совсем простых случаях можно обойтись непосредственным, органолептическим наблюдением за результатом работы системы: делает она, что должна, или нет. Но с ростом сложности немедленно растет количество внутренних параметров, значения которых никак не проявляются снаружи, но напрямую влияют на результат. Скоро без знания этих параметров становится невозможно выяснить, что это было и проч.

Конечно, в идеале система должна быть предварительно отработана на модели, все мелочи вычищены там же, а реализация на железе — заработать сразу и без проблем. Но подобные мечты высокие и светлые следует отбросить, упав вместо того в объятия реальности, где не так пойдет даже то, что по природе своей к этому неспособно.

А когда хотя бы какая-то телеметрия появляется, к ней тут же просыпается аппетит, и хочется видеть еще вот то, вот это, и пяток-другой значений отсюда. Из чего быстро возникает понимание:

1. Телеметрию необходимо сохранять для дальнейшего анализа

Человеку постороннему при слове «телеметрия» первым делом представляются живые графики во многих окнах. Да, следить за поведением системы в реальном времени — это чертовски увлекательно. Держать руку на пульсе, видеть, как система дышит, и прочее подобное. Но красивые кривые в реальном времени — вещь сильно второстепенная, и вот почему.

Во-первых, эти графики надо в чем-то рисовать. А если реалтайм-телеметрия не является значимой продуктовой фичей — под нее не будет отдельной строки в расходной ведомости. Но бог бы с ним, можно на коленке накидать приложуху с графиками («— думаете вы», (с) один анекдот), в крайнем случае просто отображать цифры в окошках. Да можно хоть в консоли смотреть!

И это, действительно, вариант. Случается даже, что от прошлых проектов доступна полноценная реалтайм-система наблюдения. Но есть еще два «но», куда более важных:

  • вне стенда, в условиях реального применения, может вообще не быть канала связи с устройством (а знать, что происходило, хочется);

  • телеметрия реального времени практически бесполезна при отработке систем управления. Потому что человек неспособен эффективно оценивать одновременно более двух-трех параметров, особенно представленных в числовом виде. Уж тем более не удастся осознать скорость их изменения или отследить кратковременные странности.

Так что на практике телеметрия поддается информативному анализу только постфактум, когда можно позволить себе строить графики и вдумчиво их изучать, сопоставляя ожидаемое поведение системы с реальным во всем разнообразии деталей и аспектов. Но для этого нужен файл!

А еще записанные данные суть ключ к одной редко вспоминаемой и не всем доступной (зависит от методики разработки), но также неоценимой возможности: их можно прогнать через исходную матмодель системы управления и посмотреть, как она вела бы себя в тех же внешних условиях при иных настройках параметров. Конечно, для этого первым делом потребно существование модели системы как таковой; плюс она должна уметь брать данные не только с модели объекта управления, но и из внешнего источника. И, конечно, у этого метода есть ограничения, накладываемые предметной областью. Но при всем при этом запись реального запуска есть уникальный материал для как моделирования поведения системы в «боевых» условиях, так и для уточнения матмодели объекта управления. Потому что нет такой модели, которую нельзя было бы немножечко допилить.

С необходимостью записи понятно. Встает масса практических вопросов, начиная с:

2. Выбор формата передачи и хранения (человеко-читаемая форма vs. бинарная)

У человеко-читаемого формата есть очевидные плюсы:

  • человеко-читаемость per se: возможность видеть цифры данных (и импортировать их для анализа во что-нибудь вроде Excel) без дополнительного ПО;

  • как следствие предыдущего пункта, простота первичной проверки организации телеметрии: передаваемые данные можно посмотреть в консоли, а записанные — в любом текстовом редакторе.

Но эти плюсы остаются плюсами только в случае, когда перечень и объем передаваемых данных крайне мал, и все потребные числа можно уместить буквально в одну строку, окидываемую взглядом. А это — вырожденный случай, потому что на практике в проектах, где телеметрия действительно нужна, условие краткости перечня не выполняется. О, нет! В таких случаях переменных много, их набор меняется от версии к версии, от режима к режиму, они нужны с разной частотой. В потоке могут присутствовать данные об однократных и случайно возникающих событиях. К телеметрии в целом, как к подсистеме, появляется определенный набор практических требований. В этом случае человеко-читаемость теряет оба преимущества: разобраться в мешанине цифр и идентификаторов в режиме реального времени становится невозможно вообще, а выбор руками нужных данных из записанного потока прекращается в квест. И все это — на фоне крайне низкой эффективности использования канала связи: из всех доступных значений байта задействованы только 10 цифр и несколько букв и символов.

В идеальном мире это не было бы проблемой: если уж данные все равно придется разбирать специальным ПО, то какая разница, человеко-читаемы они или нет, бы была формализованная структура. Но на практике всегда есть ограничения либо на пропускную способность канала связи, либо на объем хранилища, либо и на то, и на другое сразу. Поэтому бинарное кодирование — единственный практически разумный вариант. А реальная отправная точка при разработке структуры телеметрии — это

3. Конечность пропускной способности канала связи

Обычно заранее более или менее ясно, по какому каналу данные будут передаваться, и какова его предельная пропускная способность. В случае записи данных на встроенный или внешний накопитель, конструктивно объединенный с системой управления, известна также емкость этого накопителя (и, возможно, иные ограничения — например, со стороны файловой системы).

Иногда и скорости канала и емкости хранилища с запасом хватает для передачи всех имеющихся и прогнозируемых данных так часто, чтобы удовлетворить любой каприз аналитиков. В этом случае вопрос учета пропускной способности можно, с божией помощью, похерить и слать данные в канал в любых количествах: все они будут переварены.

Но обычно такой лафы не случается. Тогда приходится уточнять у заинтересованных лиц, какие данные и с какой частотой они желают иметь, оценивать потребную пропускную способность на каждый набор однородных данных, сводить всё это в единую ведомость и с этой ведомостью ходить и выкручивать интересантам руки (или договариваться полюбовно, если прокачан скилл дипломатии), добиваясь компромиссного решения. В этом решении придется:

  • сохранить баланс интересов всех участвующих сторон;

  • учесть, что эффективность использования канала не будет равна 100%: иногда звезды станут сходиться так, что данные в аппаратном буфере передачи закончились, а поток, заполняющий этот буфер, на паузе из-за того, что именно сейчас выполняется более приоритетная задача;

  • не забыть оставить запас под рост аппетитов участников.

Вариант расчета

На практике распределение пропускной способности канала удобно планировать на основе пакетной организации данных, поскольку однородные данные рационально группировать в пакеты фиксированного размера, снабженные заголовком. Про пакеты тоже будет отдельный разговор впереди, а пока в качестве примера — случай, когда все пакеты имеют одинаковый размер. Положим, имеем канал 1 Мбит/сек, то есть 125 кбайт/с. Размер пакета — 200 байт. Следовательно, теоретически достижимая частота передачи пакетов — 125000/200=625 Гц. Потребители хотят иметь один пакет с частотой 200 Гц, два пакета с частотой 50 Гц, и два пакета с частотой 25 Гц. Итого канал занят на 200+2*50+2*25=350 Гц, запас еще 625-350=275 Гц, 42% от теоретической пропускной способности. Или, оценивая в герцах, доступными остаются еще 4 потока по 50 Гц и плюс 3 по 25 Гц.

4. Приоритезация данных

От телеметрии хочется получить по возможности подробную и целостную историю состояния системы. Частота передачи различных данных — это столп, на котором зиждется подробность. Другой столп, не менее важный — относительная ценность различных групп данных. Вот первичные данные с датчиков: они появляются часто, записывать их надо столь же часто, но при этом мы практически ничего не потеряем, если время от времени в поток не будет попадать отсчет-другой. В то же время срабатывание датчика безопасности будет случаться лишь изредка, но нам крайне важно, чтобы запись об этом сохранилась в телеметрии, и попала в нее как можно раньше после того, как датчик сработал[1]. А между тем канал может быть доступен не всегда, или в ходе развития системы его запасы пропускной способности окажутся выбраны даже больше, чем на 100%, и возникнет конкуренция между данными.

По такому случаю имеет смысл завести в системе две (а то и больше) очереди пакетов, ожидающих передачи: обычные и высокоприоритетные/разовые. При наличии пакетов в высокоприоритетной очереди отсылаются сначала они, а уж после того — обычные.

Возможно, придется подумать и об относительной приоритезации обычных пакетов в случае деградации канала. Как жертвовать данными, если послать всё желаемое не удается по не зависящим от нас причинам? Можно сохранять соотношение между частотами передачи, пропорционально снижая их все. Либо отказаться от пропорциональности, и стараться передавать по возможности одинаково часто каждый из пакетов. Решение за разработчиком.

Вариант решения

Можно даже постараться обеспечить в рамках единого решения и выдерживание штатных частот передачи пакетов при нормально работающем канале, и возможность переключаться между двумя тактиками поведения при деградации канала. Пример такого решения в общих чертах:

  • запускается таймер с частотой срабатывания, равной наименьшему общему кратному частот передачи всех пакетов, и превышающей полосу канала: для примера выше (пакеты 200 Гц, 50 Гц, 25 Гц, полоса канала 625 Гц) это может быть 800 Гц;

  • создается пул объектов-наблюдателей, в котором каждый наблюдатель отвечает за расчет времени до следующей посылки «своего» телеметрического пакета, и а) осведомлен о необходимой частоте его передачи, и б) может получать уведомления о факте отправки подконтрольного пакета в канал. Период передачи подконтрольного пакета определяется наблюдателем в тактах задающего таймера: каждые N тактов пакет должен быть передан в телеметрию. Скажем, для 200-Гц пакета эта величина составляет N=800/200=4 такта;

  • скелет класса наблюдателя:

    #include

    class PacketPeriodWatcher
    {
    public:
         void onTimerTick()
         {
               if (_packetSent) {
                    _ticksLeft = sendingPeriodTicks;
                    _packetSent = false;
               }
               –_ticksLeft;
         } 

         int getTimeLeft() const
         {
               switch(_degradationHandling)
               {
               case DegradationHandling::keepRelativeFrequencies:
                    // Оставшееся время до отсылки выдается в виде числа
                    // собственных периодов отсылки данного пакета, округленных
                    // до ближайшего большего целого. Таким образом, чем реже
                    // пакет шлется в нормальных условиях, тем медленнее будет
                    // возрастать и его “величина просрочки” при возникновении
                    // аковой, и тем реже он будет получать преимущество по
                    // величине просрочки перед пакетами, в норме отсылаемыми
                    // более часто
                    if (_ticksLeft > 0) {
                         return (_ticksLeft + sendingPeriodTicks – 1) / sendingPeriodTicks;
                    }
                    else {
                         return _ticksLeft / sendingPeriodTicks;
                    }

               case DegradationHandling::equalizeFrequencies:
                    // Оставшееся время до отсылки выдается в форме количества
                    // оставшихся периодов задающего таймера. При
                    // возникновении просрочки ее величина будет расти у всех
                    // пакетов одинаково быстро, и чем дольше любой пакет
                    // задерживается, тем скорее он окажется первым в очереди
                    // на отправку
                    return _ticksLeft;

               default:
                    // добавлен какой-то новый вариант стратегии, нужен обработчик
                    assert(false);
                    return 0;
               }
         }

         void onPacketSent()
         {
               _packetSent = true;
         }

    private:
         // варианты стратегии поведения при деградации канала
         enum class DegradationHandling {
               // пытаться сохранить соотношение частот передачи пакетов
               keepRelativeFrequencies,
               // слать все пакеты одинаково часто
               equalizeFrequencies,
         };
     
         enum : int {
               // раз во сколько тиков таймера слать пакет
               sendingPeriodTicks = 4,
         };
     
         DegradationHandling _degradationHandling = DegradationHandling::keepRelativeFrequencies;
         int                  _ticksLeft = sendingPeriodTicks;
         bool                 _packetSent = false;
    };

  • по прерыванию таймера вызывается onTimerTick() для каждого из наблюдателей;

  • cуществует также таск-диспетчер канала передачи, который по завершении передачи очередного пакета и при отсутствии запросов в очереди высокоприоритетных пакетов вызывает getTimeLeft() для каждого наблюдателя из пула. Если находятся пакеты, чье время до отправки равно или меньше 0 (последнее возможно при деградации канала и означает, что пакет уже отстал от графика), выбирается тот из пакетов, чье оставшееся время минимально. Такой пакет передается в канал, а у его наблюдателя вызывается onPacketSent(). При отсутствии пакетов с оставшимся временем 0 и менее диспетчер канала инициирует переключение таска.

У этой схемы есть очевидные ограничения: требуются удобные кратности периодов передачи всех пакетов, будет достаточно часто вызываться прерывание таймера, на обработку которого тратится время процессора. Есть и подводные камни: многопоточный доступ на запись _packetSent. Однако в целом вариант вполне рабочий.

5. Буферизация

Используемый для передачи телеметрии канал может оказаться разделяемым, полудуплексным, или иметь периоды неработоспособности иной природы. Например, при записи на SD-карточку в FAT32 необходимо периодически вызывать функцию flush(), иначе есть риск при отключении питания потерять инфу за заметный промежуток времени (точнее, физически данные на флэшке будут, но в FAT-е этот факт не отразится[2]). И пока синхронизация выполняется, запись на флэшку невозможна — а данные-то продолжают поступать, и терять их не хочется. Примерно то же происходит, когда размер файла приближается к 4 ГБ, и нужно закрывать имеющийся и создавать новый файл.

Все это потребно иметь в виду, и, если условия заставляют, озадачиться буферизацией выходного потока. Придется поиграть цифрами в поисках компромисса между объемом памяти под буфер и длительностью периода недоступности канала, которую этот буфер сможет компенсировать.

6. Версионность, метаданные

Структура телеметрических данных изменяется со временем. Таков закон, обойти который смертным не дано. Если его не учесть с самого начала, то в недалеком будущем ждите великий плач и скрежет зубов при попытке глянуть архивные записи.

Через это совершенно необходимо иметь в потоке телеметрии номера версий всего, что может меняться: аппаратуры, прошивки основного и периферических модулей, самой телеметрии. Если есть возможность, хорошо бы сохранять и данные о дате и времени запуска. Без, как минимум, сведений о версии прошивки будет крайне трудно потом разобраться, в каких условиях были получены данные, а иногда — даже и понять, что они вообще собой представляют. Последнее случается при пакетной организации данных, когда идентификаторы переменных в пакете не передаются, а содержимое пакета определяется его структурой, каковая мутирует: вчера в пятом слове от начала пакета была только температура, а сегодня там еще два старших бита слова признаков. Посему следует заранее выделить и навсегда (в рамках текущего проекта) закрепить за версионными данными группу уникальных идентификаторов. Тогда, как бы ни менялось содержимое остальной телеметрии, версию ее всегда можно будет определить, а оттуда уж танцевать краковяк дальнейшего разбора.

Если ситуация позволяет, удобно формировать самоописывающиеся файлы, где в заголовке указана метаинформация: для переменных — идентификатор, имя, формат представления, цена младшего разряда, знаковость; для массивов — идентификаторы, имена, наборы переменных, составляющих пакет — и прочее, что окажется полезным по месту. Такой файл есть совершенно автономный источник информации, легко переносимый с одного рабочего места на другое.

Чаще возможность формировать и передавать метаданные недоступна. В этом варианте приходится для каждой версии телеметрии создавать внешние файлы описания, и раздавать их вместе с файлами данных. Соответствие версий файла описания и файла данных становится тогда критически важно.

Данные о версии при таком раскладе можно, в принципе, передавать в потоке телеметрии лишь однократно в начале работы — этого будет достаточно для идентификации; но подобный подход работает только в случае, если есть уверенность, что запись всегда будет содержать весь поток данных, с самого момента включения. Если же предполагается, что подключение к каналу будет происходить в случайные моменты времени, версионную информацию придется вставлять в поток регулярно, с некоторой разумной периодичностью.

7. Возможность подключения к каналу в произвольный момент времени

Если таки есть желание подключаться к каналу когда бог пошлёт, тут же встает вопрос обнаружения в потенциально непрерывном потоке данных начала ближайшей целостной единицы. Если канал по своей природе пакетный, то проблемы нет. Вот в протоколах CAN и МКИО (MIL-STD-1553) формирование и контроль пакетов осуществляется аппаратно, и обработка подключения-отключения абонентов встроена в них изначально. Получить половину пакета и смутиться не удастся, аппаратура такое поползновение пресечет на корню. А, скажем, RS-232 оперирует именно отдельными байтами, и в этом случае понять, включившись в канал, что мы видим и где концы, не так просто.

Логичным решением в этом случае будет периодически замешивать в поток данных какой-то уникальный идентификатор — токен. Значение его нужно взять таким, чтобы вероятность появления в данных его двоичного близнеца была достаточно мала. Размер — от 2 байт: любое значение однобайтного токена обречено на слишком частые коллизии и, как следствие, ложноположительные обнаружения. Брать токен длиннее 4 байт тоже не имеет особого смысла: труднее обрабатывать, и избыточность великовата. При пакетной организации потока токен можно помещать в начало или конец пакета, а при передаче потока отдельных переменных — слать после каждых N переменных, выбирая N так, чтобы расход трафика на токен оставался достаточно мал, но появление гарантировалось спустя недолгое время после подключения к каналу.

При пакетной организации потока дополнительным источником сведений для различения токена и данных будет известная длина пакета, будь то фиксированная или задаваемая в самом пакете. Если токен принят, а ближайший пакет оказывается несамосогласованным — значит, это был не токен, особенно если его значение снова обнаружилось в ходе приема пакета: тогда, возможно, настоящий токен — вот это второе обнаружение, а до того были данные. Для случая потока отдельных переменных — повторное обнаружение токена ранее, чем после приема N переменных может значить, что первое обнаружение ложное и нужно пробовать еще.

Поможет правильному обнаружению токена и

8. Контроль целостности данных

Вообще требования к контролю целостности могут быть заданы и безотносительно токенов сегментирования потока — скажем, заказчиком в ТЗ, или введены разработчиком ввиду ожидаемой плотности помех на канале связи.

Крутить же контроль целостности вручную нужно далеко не всегда. Во-первых, если канал изначально ожидается неустойчивым и подверженным помехам (как, например, радиоканал до движущегося объекта), то он на одном или более из уровней OSI уже обеспечен аппаратными средствами контроля целостности. Битые пакеты в лучшем случае будут исправлены, в худшем — не дойдут до адресата. А если контроля целостности нет, то и шанс искажения данных — низкий. Впрочем, практика может показать, что он все же высокий, и тогда лучше поправить это, пока (и если) есть возможность, на аппаратном уровне, изменив тип канала.

Во-вторых, зачастую многие параметры в системе имеют предсказуемый диапазон значений и характер изменения, и резкие выбросы в них однозначно будут идентифицированы аналитиком, как сбои. Тем более что сбои имеют тенденцию проявляться в куче переменных сразу, и из сопоставления нескольких графиков это будет видно еще лучше.

Если все-таки астрологи объявили неделю гарантий целостности и механизм придется реализовывать, то его контрольные значения удобно передавать совместно с токенами сегментирования потока (в конце пакета/после отсылки N переменных). Заодно это послужит дополнительным средством определения того, действительно ли принятое значение было признаком границы сегмента.

Собственно метод расчета контрольных значений можно выбрать любой на свой вкус, исходя из доступных вычислительных возможностей и предполагаемой степени зашумленности канала: от простого XOR-а со сдвигом до мощных хэш-функций. Тут на Хабре об этом полно информации. Можно даже развернуться и внедрить коды Рида-Соломона, гарантируя себе не только контроль целостности, но и возможность восстановления сбойных участков (как всегда, взамен на дополнительный расход трафика).

9. Идентификация и пакетирование переменных

Очевидно, нельзя слать в канал произвольный поток чисел, и надеяться потом что-то в нем понять. Поток должен быть структурирован, а переменные в нем — идентифицируемы.

В простейшем случае, когда набор параметров очень короткий, можно слать все переменные одну за одной по кругу. Известно их общее количество и размер каждой, содержимое переменной определяется местоположением в цикле отправки — сведений для парсинга потока достаточно. Но даже в этом, базовом случае нужен механизм отслеживания изменений структуры передачи: с помощью ли файлов описания, предварительной ли передачей (при старте работы системы) имен переменных в нужной последовательности, или путем жесткого прописывания последовательности передачи в зависимости от версии системы в спецПО интерпретации.

Потом переменных становится больше, возникает нужда передавать их с разной частотой. Слать вместе с каждой переменной ее имя? Адский расход трафика. Фактически оправданны два пути:

  • присвоить всем переменным числовые идентификаторы, и передавать идентификатор в паре с каждым посылаемым значением соответствующей переменной;

  • комбинировать однородные переменные в пакеты. Числовые (или текстовые) идентификаторы тогда будут у пакетов, а содержимое переменной будет определяться, как и в простейшем случае, ее позицией в пакете. Никто не мешает при этом передавать одну и ту же переменную в нескольких разных пакетах, повышая таким образом эффективную частоту ее появления в потоке.

Преимущество первого варианта — максимальная гибкость в формировании потока, но за счет повышенного расхода трафика. Преимущество второго — плотность упаковки информации, в обмен на некоторое усложнение дальнейшего анализа, и, бонусом, возможность потери информации всего пакета при порче его заголовка.

Выбор размера идентификатора определяется количеством переменных, которые надо передавать. Удобно группировать однородные переменные, давая им близкие номера, и не забывая о запасе на развитие. Скажем, есть три подсистемы, в одной 15 параметров, в другой 40, в третьей 8. Итого меньше 70 уникальных переменных. Значит, хватит 8-битного идентификатора; под данные первой подсистемы можно выделить номера, начинающиеся с 10, 20 и 30 (итого место под 30 параметров, на будущее), второй — с 40 по 90 (60 позиций), и третьей — 100 и 110 (20 позиций). Не забыть, разумеется, данные о версии — это будут параметры с номерами [0..9]. Остальное про запас.

Но такая свобода есть только при условии, что вся телеметрия находится под вашим полным контролем. А если вы, предположим, делите канал со смежниками (разработчиками составных частей изделия), или даже канал весь ваш, но от смежников поступили просьбы сохранить в потоке их родные идентификаторы, то придется думать. Особенно если у разных смежников диапазоны идентификаторов пересекаются. Тут, во-первых, рациональнее группировать переменные уже по смежникам, и, во-вторых, воспользоваться двоичной природой идентификатора: объявить его логически разделенным на поля, порубить маской на поле группы и поле внутреннего идентификатора в группе, и пусть смежники выцепляют свои данные, пользуясь выделенным им значением по маске поля группы. Но идентификатор придется сделать пошире. Зато это дает возможность, удовлетворив пожелания смежников, сохранить в то же время деление своих данных по подгруппам — только уже внутри своей группы.

Наконец, о пакетах. Сборка переменных в пакеты и передача их в таком виде, без указания идентификаторов отдельных переменных, дает максимально возможную эффективность использования канала. Тогда, как уже говорилось, поиск нужной переменной в потоке базируется на номере пакета и известном местоположении переменной в нем (смещении относительно начала пакета). Для определения границ пакетов и для различения их друг от друга каждому пакету нужен, как минимум, заголовок, содержащий, как минимум, идентификатор пакета. Что еще будет в заголовке — определяется желанием: токен сегментации, время формирования, длина пакета, контрольная сумма, какие-то специальные признаки. Опционально добавляется уникальный либо единый признак конца пакета (который во втором случае становится по совместительству токеном сегментации). Можно слать даже вырожденные пакеты, состоящие из одного заголовка — в этом случае пакет выполняет роль переменной, сигнализирующей о чем-либо.

Зачастую канал изначально имеет пакетную природу — как те же CAN и МКИО, причем оба они разрешают, например, посылки переменной длины, и оба содержат средства контроля целостности. Тогда для задания пакетов можно пользоваться готовой аппаратной инфраструктурой, а это снимает многие головные боли с разработчика. Однако всякое аппаратное решение имеет ограничения: например, у CAN (в базовом формате пакета) это 11-битный идентификатор и максимум 8 байт данных в пакете, а у МКИО — запрос-ответная организация канала, и всего лишь 5 бит на поле подадреса оконечного устройства, т.е. на номер пакета (зато максимум 32 16-битных слова в пакете, что достаточно много в сравнении с CAN). К чему это всё: придется сначала оценить, достаточно ли аппаратных возможностей канала для задач телеметрической подсистемы, и если нет — возводить слой абстракции поверх используемого аппаратного протокола.

10. Формат времени

Если есть какой-то параметр, важность которого трудно переоценить — так это время. Без знания моментов формирования данных вся затея с телеметрией теряет смысл. В принципе, можно фиксировать время на приемной стороне, при получении переменных/пакетов, но тут есть риск исказить сведения о последовательности возникновения событий внутри системы — наружу-то пакеты выдаются с разной частотой, поэтому пакет с реакцией может быть выдан раньше пакета с причиной. Кроме того, не всегда на приемной стороне в принципе доступно достаточно точное знание момента выдачи пакета со стороны передающей, в силу программно-аппаратных ограничений платформы и/или канала (например, из-за приоритезации пакетов в той же шине CAN). Наконец, при этом подходе невозможно знать внутреннее время системы, а без него во многих случаях никакой содержательный анализ не провести.

Поэтому возникает нужда передавать время системы в потоке данных. Спрашивается: как это лучше делать?

Прежде всего, время следует передавать в форме целочисленной переменной, дальше будет сказано, почему. Выбрать тогда остается размер переменной и цену ее младшего разряда. Оба этих параметра определяются сочетанием пары же условий: насколько точно время нужно знать, и как велика максимальная длительность непрерывной работы (при условии, конечно, что время нужно знать от момента включения; если требуется только знание относительной удаленности событий друг от друга, переменной времени можно позволить циклически переполняться, это не станет помехой).

С ценой младшего разряда все довольно просто: во многих случаях в системе присутствует какой-то базовый высокочастотный цикл, чаще которого ничего не происходит — скажем, опрос первичных датчиков с частотой 1000 Гц. В этом случае за единицу времени рационально принять период этого цикла (1 мс в примере). Все значимые события в системе так или иначе будут подчинены периоду опроса датчиков, и моменты их возникновения будут кратны периоду опроса. Если же такого цикла нет, придется пытать аналитиков относительно того, какая точность знания времени их устроит.

Выбор размера переменной времени зависит от ожидаемой длительности непрерывной работы. Формально нижняя граница количества бит определятся как log2(максимум времени/цена младшего разряда), округленный до ближайшего большего целого. А прикинуть, хватит разрядности или нет, можно и на глаз, зная предельное целое число, умещающееся в выбранное слово. Например, 16-битная переменная переполнится в нашем примере через 65,536 секунды, а 32-битная — через без малого 4,3 миллиона секунд. Для большинства задач достаточно.

В принципе, если запись данных ведется непрерывно с самого начала работы, переменной тоже можно позволить свободно переполняться: истинное время будет восстановлено при обработке. Но если планируется подключение к каналу в произвольные моменты времени, придется выделить под время побольше места. В качестве альтернативного решения можно разделить переменную времени на старшую и младшую части, и передавать младшую часть настолько часто, насколько это необходимо (в каждом пакете/каждые N переменных), а старшую — лишь время от времени, но не реже периода переполнения младшей части. Лучше, конечно, еще чаще: возможны сбои, или отключение от канала до момента передачи старшего слова времени. Таким способом можно записывать совершенно гигантские времена с огромной точностью.

К слову, в системе могут фиксироваться и иные события — случайно возникающие внешние прерывания. Хотя моменты появления этих событий не могут быть точно представлены в выбранных единицах шкалы времени, это в большинстве случаев и неважно: все равно значимая реакция (какие-то действия, отличные от регистрации факта появления сигнала) происходит обычно не раньше начала очередного цикла опроса датчиков. Поэтому с практической точки зрения достаточно знать не момент, когда внешнее событие произошло, а момент, не позже которого оно было обработано. Если все-таки точное время регистрации события критично, то под него заводится отдельная телеметрическая переменная со своим именем, и в нее заносится момент первичной регистрации с точностью, заданной аналитиком с учетом возможностей системы.

Так в чем все-таки плюс целочисленного времени? Точность. Точность в таком представлении никогда не теряется, независимо от того, сколь долго продолжается запись. В этом его кардинальное отличие от формата с плавающей точкой: можно, конечно, передавать время как переменную типа float (32-битное одинарной точности по IEEE-754), и потом иметь при обработке сразу готовое бинарное значение, но у float-а точность – лишь 7 с хвостиком десятичных разрядов. В нашем примере это значит, что, начиная с примерно десяти тысяч секунд записи, позиции точек данных станут дерганными, и тогда о качестве и удобстве анализа можно будет забыть. При 100-мкс точности проблемы полезут уже где-то с тысячи секунд. Можно, конечно, подключить всю мощь double-а с его практически 16-ю десятичными разрядами, но, камон, это 64 бита данных. Передавая время в 64-битной целочисленной переменной с точностью в 1 наносекунду, можно обеспечить непрерывную запись на протяжении 18 с лишним миллиардов секунд, то есть 580 с гаком лет. Неплохо, не правда ли.

Минусы целочисленного времени: при подготовке данных к анализу придется кучу раз умножить целое число на 0,001.

11. Формат прочих переменных

Формат содержательных переменных определяется их природой, но во многих случаях применение типов с плавающей точкой не требуется: данные заведомо лежат в узком диапазоне значений, и могут быть с достаточной точностью представлены в целочисленном формате, со знаком или без, с определенной ценой младшего разряда и, опционально, с фиксированным сдвигом относительно нуля.

Отдельно стоит сказать про данные первичных датчиков: они обычно приходят в виде целых чисел с того или иного варианта АЦП, поэтому при разрядности АЦП 16 бит и меньше такие данные — идеальный кандидат на передачу в канал «как есть». Да, вы вряд ли потеряете в точности при переводе их из 16-битных АЦПшных во float, где под мантиссу отведено 24 разряда, но расход трафика определенно возрастет. Относительно 18- и более битных АЦП уже нужно смотреть по месту, там экономия канала за счет целочисленности будет скромнее.

В итоге все равно всё определяется шириной канала, а float- и double-данные удобны своей готовностью к непосредственному отображению и использованию, так что выбор за вами.

12. Проблемы многопоточного доступа

В сколь-нибудь сложной системе данные всегда генерируются во многих точках. Но даже если все они в итоге доступны из одного места, то, как минимум, поток исполнения, формирующий набор данных к отправке, и поток, управляющий выдачей данных в канал — это разные потоки. И это еще минимальный случай, на практике потоков больше. А любой многопоточный доступ к данным — это всегда более или менее проблемы в реализации, и всегда — трехсторонний компромисс между быстродействием, консистентностью и количеством потребной памяти.

Если запасы оперативки и процессорного времени позволяют — лучше всего копировать готовые блоки данных (группы переменных, пакеты) в очередь отправки, оснащенную механизмом межпоточной синхронизации. Скопированные в очередь, данные к моменту передачи в канал точно останутся целостными и согласованными.

При нехватке ОЗУ придется создать по единственному экземпляру каждой отсылаемой структуры, и защищать доступ к этим экземплярам мьютексами, или чем там оптимально позволит операционка — это для пакетного варианта передачи. Для варианта с потоком переменных рациональней охранять доступ к группе переменных сразу, иначе затраты времени на синхронизацию на порядки превысят длительность собственно записи/чтения данных. Хотя и такое может быть приемлемо, если процессор избыточно крут для выполняемой задачи.

О необходимости защиты доступа

Чуть подробнее о том, зачем вообще нужна защита доступа. Ведь тут в одном месте идет запись, в другом — чтение, одновременное изменение данных из двух мест невозможно. Так и пиши здесь в ТМ-структуры в потоке с высоким приоритетом, там читай из них в канал в потоке с низким, чем плохо?

И так, правда, можно — но опять только в случае, когда запас процессорного времени поистине велик. Потому что в реальности практически неизбежны моменты 100%-ной занятости процессора. Вот приходят многие прерывания, накладываются одно на другое, вызываются их обработчики, и на это время потоки с высоким приоритетом подтормаживаются, не говоря уже о низкоприоритетных. А когда обработка прерываний закончилась, высокоприоритетные потоки снова кидаются исполнять свои обязанности, формируют новые значения в тех разделяемых хранилищах — а низкоприоритетный поток как раз находился в середине процедуры чтения одного из них, когда был остановлен. Все, консистентность блока данных разрушена: половина его окажется составлена из данных одного цикла, другая половина — из данных другого.

Кстати, о реакции контрольной суммы на такое безобразие: если сумма формируется в процессе записи данных в структуру — на стороне «писателя» — то потом при анализе данных определится ошибка, и сбой не пройдет замеченным. Правда, его природа будет неочевидной. А если сумму вычислять прямо в процессе передачи данных в канал, и отсылать ее туда последней — тогда и она ничего не покажет, потому что со стороны потока передачи нельзя понять, менялся ли блок данных за время чтения. Это к вопросу о непродуманных оптимизациях логики.

И хорошо еще, если просто разные части пакета будут принадлежать разным циклам вычислений. Консистентность пакета не всегда критична, для тех же показаний датчиков это может быть неважно: ну, потеряется отсчет, одиночные точки редко важны, главное тенденция. Но нарушаться может и целостность переменных как таковых, об этом ниже.

В общем, не стоит отказываться от синхронизации, не убедившись в ходе предварительных тестов на реальном железе, что затраты времени на нее неприемлемы. Если частичный или полный отказ все же необходим — напишите, по крайней мере, нормальные функции сериализации (виртуальные функции С++/указатели на функции С в помощь). Не применяйте традиционный подход с кастом блока памяти структуры к void* или char*, и дальнейшим копированием его данных в выходной буфер канала. Во-первых, аппаратный регистр канала наверняка объявлен как volatile-указатель, поэтому данные все равно придется копировать в него вручную побайтно (пословно), выиграть в скорости за счет использования memcpу не удастся из-за этого самого volatile — вызов memcpу не скомпилируется, и это замечательно. А непосредственное ручное копирование даст совершенно ничтожный выигрыш во времени по сравнению вызовом функции сериализации. Во-вторых, в продуманной функции можно будет во многих случаях избежать еще более неприятной проблемы: при прямом копировании данные способны стать несогласованными даже в пределах одной переменной! Получается это по следующим причинам:

  • зачастую канал по своей природе оперирует отдельными байтами, и поэтому передача в него двух- и более байтной переменной может прерваться посредине, новой записью в нее в другом потоке. В функции сериализации по этому поводу можно предварительно копировать переменные размером в слово или менее во временную переменную, что будет сделано одной машинной операцией;

  • даже если канал оперирует машинными словами, а элементы копируемой структуры выровнены по границе слов, и для элементов, меньших или равных размеру машинного слова, запись и чтение ячейки памяти можно считать атомарными операциями — то для элементов, больших аппаратного слова, на это всё равно рассчитывать не приходится: скажем, величина типа double на 32-битном процессоре будет писаться и читаться минимум за две машинные операции;

  • самое же главное — то, что обычно для удобства блочного копирования структура объявляется примерно таким кодом:

#pragma pack(push, 1)

struct TmVar
{     
    uint8_t    id;     
    uint32_t   val;
}; 

struct TmArray
{     
    uint8_t    id;     
    uint32_t   timeTicks;     
    uint8_t    val1;     
    uint16_t   val2;     
    float      val3;
};

#pragma pack(pop)
  • то есть, чтобы не расходовать место впустую, объявление структуры обрамляется директивами #pragma pack(push, 1)/ #pragma pack(pop) (все помнят про стаффинг — выравнивание по умолчанию элементов структуры по границам машинного слова, с заполнением промежутков пустыми байтами?). А в плотном варианте упаковки выравнивание, в общем случае, исчезает, так что работа с вообще любой переменной, кроме однобайтных, может пойти по частям и при записи, и при чтении.

Поэтому, оперируя данными без применения механизмов синхронизации, пользуйтесь сериализаторами. Если это совсем не вариант, то, по крайней мере, формируйте каждую структуру внимательно, перетасовывая ее элементы так, чтобы они были выровнены по подходящим для них границам. Это не устранит проблему искажений, но уменьшит шанс ее проявления.

На сладкое: существуют платформы с размером char-а, отличным от 8 бит (например, опциональные 32, как в 1967ВН044), где вся эта красота прирастает новыми, веселыми фокусами сопряжения форматов данных платформ источника и приемника. Возрадуйтесь!

13. Циклические счетчики

Оставшиеся вопросы относятся скорее к обеспечению удобства анализа, чем каких-то важных системных свойств телеметрии. Тем не менее, они взяты из практики, показали себя полезными и облегчающими жизнь, а потому достойны отдельного упоминания.

Блоки управления по природе своей общаются в бесконечном цикле с различными периферическими устройствами и исполнительными механизмами. В этих устройствах зачастую стоит какая-то своя электроника, и для связи с ними пишутся подходящие несложные (или сложные) протоколы. При этом когда что-то идет не так, иногда трудно понять, с чьей стороны проблема и на каком уровне она возникает.

Поэтому для упрощения первичной диагностики полезно предусмотреть в протоколе с каждым из таких устройств буквально пару байт, передаваемых со стороны устройства: один из них будет содержать циклический счетчик количества принятых от нас управляющих пакетов, а другой — количества ответных пакетов, переданных, по мнению устройства, с его стороны. Счетчики увеличиваются на единицу с каждым принятым нашим/отправленным его пакетом, а при достижении величины 255 сбрасываются на 0 в следующем такте приема/передачи.

На графиках тогда будет видна характерная «пила», и наблюдение этой пилы многое скажет даже при беглом взгляде на картинку. Стабильная ровная пила — все отлично. Обе пилы, синхронно меняющие наклон — иногда наши команды не доходят до устройства, но оно успешно отвечает на каждую, нужно смотреть канал связи. Остановка пилы — потеря связи. Вырождение в прямую линию — мозги периферии зависли целиком или частично, а выходной пакет либо раз за разом передается из аппаратного буфера по запросу, либо цикл отправки жив и шлет ответы из памяти с общим доступом, но эта память не обновляется — цикл управления умер.

Конкретная интерпретация кривых определяется спецификой канала связи и логики общения, но, в любом случае, годная вещь.

14. Именование переменных. Иерархические имена

Это в направлении заботы о народе: при выборе названий телеметрических параметров крайне желательно сохранять систему обозначений, привычную для людей, работающих с этими параметрами. Скажем, если традиционно перегрузка записывается как NY, не следует называть соответствующий параметр rel_total_accel_Y. Для программиста такое название, может, и будет более говорящим. Но специалист по управлению будет каждый раз, кривясь, вспоминать, как тут перегрузку-то обозвали? Аксель? Что-то там… Может быть, специалист будет кричать. Душераздирающее зрелище.

Еще не стоит без веских причин переводить на английский язык имена, которые аналитики привыкли видеть в форме русскоязычных сокращений, записанных латиницей. Если команда «Готовность 1», чье привычное всем имя — Kgot1, окажется вдруг поименована как CmdReady1, аплодисментов не будет и цветы вам не пришлют.

В целом, аналитикам должно быть комфортно работать с данными, и привычные имена параметров — значимая составляющая такого комфорта. Если, паче чаяния, вам придется лично заниматься незнакомой теоретической областью и формировать имена по собственному разумению, стоит потратить некоторое время, привыкая к принятой в этой области символике, и в телеметрии использовать ее же. Велик шанс того, что подходящий теоретик будет-таки подключен позже, и лучше вам сразу говорить с ним на общем языке, в том числе — глядя на записи пусков.

Иерархические имена. Подобно тому, как идентификаторы переменных стоит группировать по подсистемам, имена тоже стоит предварять префиксами подсистем, к которым они принадлежат. Когда в списке переменных многие десятки, а то и сотни имен, гораздо проще прокрутить его до плотненькой группы, где все имена начинаются с «nav_angles», и там кликнуть галочки «nav_angles_psi», «nav_angles_gamma» и «nav_angles_theta» одна поблизости от другой, чем искать во всем списке, где примерно расположены «гаммы» среди множества строк на g, а потом еще выбирать свою «gamma» среди визуально очень схожих «gamma0», «gamma_t1» и «gammaMax», повторяя потом это трюк с «psi» и «theta».

Второй плюс иерархичности — возможность найти переменную в списке, не помня ее точного имени. Некоторые параметры нужны редко, и из-за этого их названия забываются. Потом в какой-то момент в них возникает однократная нужда, и тогда вспомнить, глядя на неструктурированный перечень, как же, черт возьми, оно называлось, невероятно сложно. Зато при этом обычно хорошо знаешь, к какому модулю этот параметр относился, и вот тут иерархические имена снова очень удобны: проматываешь до блока имен нужной подсистемы, и среди тех полутора-двух десятков нужный параметр находится очень быстро.

Понятное дело, что рекомендация теряет смысл, если у вас развитая система обработки: в таковой, скорее всего, уже предусмотрена иерархическая организация имен своими средствами. Тогда имена сразу будут те, что наиболее удобны для аналитиков. Хотя в истинно развитой системе могут быть предусмотрены и псевдонимы на случай разного именования одной и той же переменной в разных массивах/разных версиях телеметрии… фантазия телеметристов, к сожалению, иногда не знает границ.

Приложение А. Случаи из практики

Поскольку ода вышла какая-то куцая — ни дифирамбов нормальных, ни панегириков, следует хотя бы потравить коротких баек по теме. Тут некоторые истории известны мне только в пересказе, поэтому могут быть неточности, но основная идея сохраняется.

1/1000 vs. 1/1024 секунды

В одном проекте часть расчетов в устройстве велась на основе данных, поступающих из внешнего источника. Экземпляров этого источника тоже было несколько, и они, с точки зрения устройства, не различались: протокол связи был единым. В том числе с такого внешнего источника поступало время, как раз целочисленное, цена младшего разряда которого, в соответствии с протоколом, составляла 1/1000 с.

И вот в одном из включений — бац, и устройство начинает вести себя неожиданно. Расследование показывает, что результаты расчетов по данным источника расходятся с теми цифрами, которые можно косвенно получить из расчетов, выполняемых внутри устройства по его собственным данным.

Колокола, аврал, начинаются раскопки. Одни люди проверяют всё со своей стороны, другие со своей, перетряхивается модель, тесты, отработки — все хорошо! Проблема, однако, сохраняется. Вносятся сложные идеи о задержках в канале, о влиянии шумов и о неучтенных эффектах первичных датчиков на внешнем устройстве. Всуе.

В итоге, как понятно из заголовка, оказывается, что в данном экземпляре внешнего устройства цена младшего разряда была заменена, с 1/1000 секунды на 1/210. Эта разница в 2.5%, накапливаясь в расчетах квадратично, давала заметную погрешность результата на периоде вычислений.

Выяснилось это благодаря именно телеметрии, сопоставлению ее из различных устройств. Проверка того экземпляра внешнего источника на стенде разработчиков ничего не показывала, потому что стенд был осведомлен об изменении цены разряда в данном экземпляре, и проверки шли с учетом этого изменения, с закономерным успехом.

Проблемы ускорения, гласности и перестройки

В одной модифицируемой системе подвижный модуль счислял своё положение с большой ошибкой, хотя ничто к тому не предрасполагало. В частности, данные первичных датчиков лежали вполне в пределах допустимого, ни необычные шумы, ни сбои в них не наблюдались, все остальные составляющие для вычислений были корректными. И, тем не менее, результаты автономного расчета положения отличались от данных, получаемых из внешних источников. Конечно, коррекция по внешним данным позволяла исправить положение, но в реальности такие данные должны были быть доступны лишь изредка, при этом точность нужно было выдерживать всегда — а тут такая засада уже в самом начале работы.

Довольно скоро аналитик сообразил, что наблюдаемое поведение связано с новой конструкцией двигателя. Характер изменения ускорений, придаваемых им модулю (величина то ли первой, то ли второй производной ускорения) был такой, что счисление положения привычным методом давало недопустимую погрешность. Хорошо, что это обнаружилось на этапе запусков с коррекцией и дало возможность поменять алгоритм так, чтобы свести эту погрешность к приемлемой. Если бы не данные телеметрии, успешные итоги запусков с коррекцией стали бы основанием для запусков без нее, каковые и увенчались бы закономерным фейлом.

Случаи с беспилотником

Один раз здорово помогли те самые циклические счетчики. Беспилотник после отрыва от земли вдруг потерял стабильность и через несколько секунд упал (сломав, собака, в очередной раз часть дорогущего набора подъемных винтов). Тогда в телеметрии еще не было счетчиков ошибок канала CAN, потому что разработчик считал эту шину весьма стойкой к сбоям, и — с учетом того, что неудачные посылки отправляются контроллером CAN вновь и вновь, пока не будут приняты — с настройками по умолчанию надежной линией доставки команд. Поэтому был только циклический счетчик удачных посылок. И вот, судя по нему, частота выдачи сигналов управления на двигатели деградировала со штатных 50 герц аж где-то до 8, приведя к громадному фазовому сдвигу в контуре стабилизации, возбуждению и потере управляемости.

Как оказалось, сгубила аппарат именно способность CAN автоматически повторно слать неудачные пакеты. Из-за мороза нарушилось качество контактов в одном из абонентов, при полётной вибрации оттуда перли помехи, пакеты не доходили с первого раза, CAN упорно продолжал их слать, а в это время новые управляющие данные нельзя было отдать в выходной буфер CAN, потому что тот был еще занят. В итоге к моменту, когда пакет таки оказывался принят двигателем, он уже безнадежно устаревал. Отсюда вывод: если старый пакет управления не удалось отослать до появления нового — следует прекратить попытки и заменить пакет на более новый. Он, по крайней мере, будет адекватен текущей ситуации, и с ним шанс выжить выше, чем со старым.

Позже на этот же беспилотник ставили радиовысотомер. Необходимость применения именно радиовысотомера во весь рост встала после того, как аппарат, в попытках сесть, грациозно танцевал над свежим снегом на лидаре, взметая под собой пургу и приводя лидар в неистовство. Закончилось шоу тем, что перегрелся один из двигателей (остальные были близки к этому), аппарат послал такую жизнь к черту, выключил все четыре движка сразу и обрушился вниз, разметавшись по земле. С высотомером тоже все оказалось не слава богу, и это тоже выяснилось уже потом, при сравнительном анализе данных лидара и высотомера после очередной жесткой посадки: на скоростях движения выше 3 м/с в одном из направлений РВ принимался динамически врать об оставшемся расстоянии, и вычисление скорости движения на основе его данных создавало жуткий кавардак.

В другой раз аппарат раскрутил двигатели, поднялся, мелко подрагивая, метров на 15, а там вдруг резко накренился и, поочередно завывая движками и болтаясь по крену из стороны в сторону, понесся к земле. Анализ телеметрии показал, что была допущена ошибка при вводе передаточных чисел закона стабилизации по крену: их увеличили в несколько раз. То, что квадрик поднялся, объяснялось чистой случайностью: при подъеме возмущения были достаточно малы, чтобы контур стабилизации оставался устойчивым при их отработке — а наверху дунул ветер, и пошла писать губерния. К счастью, в тот момент величины передаточных чисел уже писались в телеметрию, хотя они тогда были статическими и большого смысла писать их не было: можно было увидеть эти числа непосредственно в коде прошивки. Но то еще когда бы пришло в голову, а тут сравнение цифр в каналах крена и тангажа в рамках анализа типового набора переменных сразу показало виновника.

В общем, господа, аве телеметрии! Надеюсь, у хабровчан найдется, чем дополнить этот кодекс и с чем поспорить: две-то головы лучше, чем одна.


[1] Можно сделать финт ушами и сопроводить запись о таком событии биркой времени его возникновения, разместив саму запись в потоке позже, когда будет возможность. Но это осложнит процедуру анализа. Хотя иногда приходится делать и так, когда точность знания момента очень важна, а принцип передачи времени и структура канала не дают возможности эту точность обеспечить даже с учетом приоритезации.

[2] Жизненный совет: очищайте флэшку перед установкой в модуль, чтобы не путаться потом, какие файлы новые, а какие остались с прошлых включений. А перед ответственным запуском — форматируйте, причем «полным» форматированием, а не «быстрым». Когда FAT слетит целиком, останется возможность руками вытащить данные из образа флэшки, и они будут расположены в этом образе последовательно.

 

Источник

Читайте также