[English](ARCHITECTURE.md) # Архитектура CSV-телеметрия (F1 22 или ACC) превращается в Markdown-отчёт через короткий линейный конвейер. Всё на стандартной библиотеке Python; ни фреймворка, ни глобального состояния. ## Файлы ``` telemetry/ __main__.py Точка входа CLI: разбор аргументов, диспатч режима, stdout. Без аргументов -> GUI. parser.py CSV -> Lap (frozen dataclass: session, track, setup, ch, n). segments.py Lap -> повороты и прямые. Детекция зон + нумерация. corner_metrics.py Подсчёт по зоне: точка торможения, lock/spin, накат, температуры. resample.py Адаптивная сетка по дистанции + интерполяция каналов для трассы. report_common.py Шапка, строки легенды, md_table, token_estimate, game_of, load_prompt, атомарная запись. report_technique.py Отчёт о пилотировании (повороты, прямые, расход, трасса). report_setup.py Отчёт о машине (сетап, баланс, шины, тормоза, подвеска, фазы). report_compare.py Два+ круга рядом, дельта по поворотам и накопленная. report_race.py Папка кругов: лаптаймы (с позицией), стинты, износ, тепло, ERS. Дедуп дублей номеров кругов. report_profile.py Папка кругов: дешёвые тенденции по поворотам (медиана на поворот, по доминирующему составу) + авто-выбор кругов для разбора. Без трассы. rename.py Вставка типа сессии + номера круга (P1_L7) в имя; с races_dir — сортировка в races//// и рекурсия по подпапкам. gui.py Окно tkinter с двумя вкладками (F1 22 / ACC); память последней папки по режиму; генерация/переименование в потоке. Предупреждает (без блокировки), если определённая игра не совпадает с активной вкладкой. prompts/ Промпты LLM, подпапки ru/ и en/, по одному файлу на режим. Добавляются в конец каждого отчёта. Переопределения для ACC лежат в prompts//acc/.md; при отсутствии — откат к F1-промпту. tests/ Набор pytest + фикстуры-круги. ``` ## Как CSV становится отчётом 1. **`__main__.main`** разбирает `<режим> <файлы…>`, проверяет количество (technique/setup = 1 файл, compare = 2+, race = 1 папка) и диспатчит. 2. **`parser.load_lap`** читает файл как UTF-8 и делит на четыре мета-блока (session, track, setup) плюс таблицу телеметрии на 108 каналов. Имена каналов нормализуются срезанием суффикса ` [unit]`. Каждая колонка, целиком состоящая из нулей, отбрасывается — игра пишет много мёртвых каналов (см. «Подводные камни»). 3. **`segments.detect_corners`** проходит по сэмплам, помечает сэмпл как «в повороте», если `|Steer| > STEER_ON` или `Brake > BRAKE_ON`, сливает соседние зоны с зазором меньше `MERGE_GAP_M`, отбрасывает зоны короче `MIN_LEN_M` и нумерует оставшиеся T1..Tn по дистанции. Для каждой зоны вызывается **`corner_metrics.corner_from_zone`**, которая извлекает точку торможения, апекс, передачу, точку полного газа, пик тормоза/gLat, относительные lock%/spin%, дистанцию наката и пик температуры задней шины. `straights` заполняет промежутки между поворотами. 4. **`resample.adaptive_points`** строит сетку по дистанции — плотную внутри поворотов (и на подходе) и редкую на прямых; шаги по умолчанию берутся из медианного интервала сэмплов круга через `auto_steps`, так что более плотная сырая телеметрия даёт более плотную трассу. **`sample_at`** интерполирует каждый канал на эту сетку (ближайший сэмпл для дискретных вроде Gear/DRS). 5. Функция **`report_<режим>.generate(…, lang, include_prompt=True)`** собирает Markdown из помощников `report_common` (`header_block`, `md_table`, `legend`) и данных выше. **`game_of(lap)`** читает поле `Game` из сессии и возвращает `'acc'` (если значение начинается с `"ACC"`) или `'f1'`; генераторы отчётов используют это, чтобы убрать разделы DRS/ERS/гибрид для ACC и заменить таблицу сетапа пометкой, когда все значения нулевые (ACC всегда так экспортирует). При `include_prompt` вызывает **`load_prompt(mode, lang, game)`**, которая для ACC сначала ищет `prompts//acc/.md` и откатывается к `prompts//.md` при его отсутствии, затем добавляет результат после разделителя `---` (CLI `--no-prompt` / чекбокс GUI это отключают). Финальный текст пишется через **`write_report`** атомарно (временный файл + `os.replace`), возвращается `(abs_path, tokens)`. Перед генерацией `__main__` пропускает прогон через **`is_fresh(report, sources)`**, если свежий отчёт уже есть (override — `--force`). 6. **`__main__`** печатает путь и `~N tokens (budget 60k)`. **GUI** показывает тот же кортеж в логе вместо печати. ## Как добавлять **Новый режим отчёта.** Создай `telemetry/report_x.py` с `generate(lap, out_path) -> tuple[str, int]` (или `generate(laps, out_path)` для multi-lap). Собери текст из помощников `report_common` и заверши `return write_report(out_path, text)`. В `__main__.py` добавь `'x'` в список режимов и ветку диспатча, которая считает путь вывода (`report_path(input, 'x')`) и вызывает твой `generate`. Добавь режим в радиокнопки GUI и `_worker_generate`, если нужен в интерфейсе. **Новый канал трассы.** Сэмплируй его в построителе трассы через `sample_at(lap, 'ChannelName', dists)`, добавь в заголовок трассы и `resample.format_trace_row`, и заведи строку `LEGEND_*` в `report_common.py`, чтобы колонка была задокументирована. **Новая метрика поворота.** Добавь поле в dataclass `Corner` в `segments.py` и посчитай его в `corner_metrics.corner_from_zone`. Поскольку `Corner` — frozen, задавай через конструктор. ## Допущения и тюнящиеся константы Два рода констант. **Детекция** — пороги в единицах телеметрии, одинаковы во всей F1 22: решают, *где* граница, а не вывод, поэтому не тюнингуются под трассу. **Интерпретация** — доменное суждение, которое реально варьируется; в отчётах рядом с любым флагом/вердиктом всегда печатается сырое число, так что спорный порог не прячет данные. | Константа | Файл | Род | Основа / чувствительность | |-----------|------|-----|---------------------------| | `STEER_ON 6`, `BRAKE_ON 5`, `BRAKE_MARK 20`, `FULL_THROTTLE 98` | `segments` / `corner_metrics` | детекция | % от диапазона ввода, одинаково в игре | | `MERGE_GAP_M 40` + `merge_gap_for` (clamp 28–60) | `segments` | детекция | **адаптивно**: масштабируется по `Tracklen` вокруг якоря Zandvoort — шиканы/длинные трассы самотюнятся | | `MIN_LEN_M 15`, `_SAFE_SPEED_FLOOR 30`, окно газа `0.3 с` | `segments` / `corner_metrics` | детекция | геометрия / защита деления / дебаунс | | `resample` dense=median, sparse=8×median | `resample` | детекция | самонастройка под частоту данных | | `LOCKUP −10 %`, `SPIN +8 %` | `segments` | интерпретация | флаги severe-only; сырые `lock%`/`spin%` всегда показаны | | `FUEL 0.03–0.05 с/кг` | `report_compare` | интерпретация | зависит от трассы; выдаётся **диапазоном** с печатью ставки | | `diff ±0.5` US/OS | `report_setup` | интерпретация | относительно baseline скольжения круга; сырое front/rear показано | | profile `*_REL` множители + `*_FLOOR` / `TR` поля | `report_profile` | интерпретация | множители **относительно медианы сессии**; полы — шумовые гейты; медианы в таблице | Если будущая трасса потребует другой группировки поворотов — `merge_gap_for` уже адаптируется по длине; ручная правка нужна лишь на совсем нестандартной конфигурации. ## Подводные камни **Файлы в UTF-8, а кодировка ОС здесь — cp1251.** `parser.load_lap` всегда передаёт `encoding='utf-8'`; открытие файла телеметрии дефолтным кодеком падает с `UnicodeDecodeError` на первом не-ASCII байте. `rename.read_lap_number` читает только первые пять строк, но обязан использовать ту же кодировку. **Многие каналы в этой версии игры целиком нулевые** — `Torque`, `IcePower`, высоты подвески, нагрузки, посекционные температуры шин, `Clutch`, `Handbrake` и другие. Парсер отбрасывает любую полностью нулевую колонку, поэтому код ниже должен трактовать отсутствие канала как «не записан», а не «ошибка». **`WheelSlip*` — это разница скоростей в км/ч, не отношение.** Переднее значение −4.5 при торможении — норма. Поэтому lock/spin считаются как *относительное* скольжение (`slip / max(speed, 30) * 100`) с порогами −10 % / +8 %; сырые значения слипа несравнимы между скоростями, и базовые уровни передней/задней оси различаются. **`Steer` — это процент (−100..100), не доля.** Малые значения на прямой — всё равно проценты. **Строка сетапа заканчивается лишней запятой** (пустой хвост `FuelLoad` в части экспортов). Парсер отбрасывает пустые хвостовые поля, чтобы в словаре сетапа не было ключа с пустой строкой. **Rename с `races_dir` распределяет по папкам и рекурсит.** `rename_unprocessed(targets, races_dir)` когда `races_dir` установлен: (1) сканирует всё дерево подпапок (`os.walk`), (2) читает метаданные из CSV (`read_metadata` → lap, event, track, game), (3) вставляет токены если их нет, (4) создаёт `races/F1 22|ACC//Practice|Qualifying|Race|Sprint/` и перемещает туда файл, (5) даже уже переименованные файлы переносятся если лежат не там где надо, (6) пропускаются только файлы, которые уже находятся в нужной папке с нужным именем. Без `races_dir` работает как раньше — переименовывает в исходной папке. **Отчёты выходят рядом с входом, а не рядом с программой.** `report_path` выводит папку `reports/` из папки *входного CSV*. Режим `race` кладёт отчёт внутрь папки сессии (`/reports/`). Перемещение инструмента не меняет, куда попадают отчёты. **Игра определяется из поля `Game` в метаданных и передаётся сквозь всю генерацию отчёта.** `game_of(lap)` читает `lap.session['Game']`; для Assetto Corsa Competizione поле начинается с `"ACC"`, для F1 22 — иначе. Каждый генератор отчёта вызывает `game_of`, чтобы решить, какие секции выводить. Флаг CLI `--game` позволяет переопределить определение, патча поле сессии до диспатча. **Сетап ACC — всегда нули, и таблица параметров намеренно не рендерится.** CSV-экспорт ACC записывает нули во все поля сетапа. `report_setup` проверяет, все ли значения нулевые, и, если да, заменяет таблицу параметров поясняющей пометкой вместо вывода бессмысленных нулей. **События сессии в ACC — полные слова; в F1 22 — короткие коды.** F1 22 пишет коды вроде `P1`, `Q1`, `R`. ACC пишет `Practice`, `Qualifying`, `Race`. Модуль переименования нормализует полные слова ACC до компактных канонических кодов (Practice→P, Qualifying→Q, Race→R) перед вставкой в имя файла и выбором папки назначения. Вставляемый токен совпадает с тем, что ожидает распознаватель паттерна сессии, поэтому повторный запуск rename на уже обработанных файлах ACC идемпотентен (они пропускаются, а не получают двойной тег).