mirror of
https://github.com/Horizontal42/f1-telemetry.git
synced 2026-11-11 01:52:08 +03:00
initial release
This commit is contained in:
commit
5b6b68748f
43 changed files with 5523 additions and 0 deletions
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Telemetry data and generated output (live outside this repo, ignored if nested)
|
||||||
|
*.csv
|
||||||
|
reports/
|
||||||
|
races/
|
||||||
|
|
||||||
|
# ...but test fixtures must be tracked
|
||||||
|
!tests/fixtures/
|
||||||
|
!tests/fixtures/*.csv
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor / tooling
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
.aider*
|
||||||
|
.cursorrules
|
||||||
|
*_ai_notes*
|
||||||
|
llm_context*
|
||||||
58
ARCHITECTURE.md
Normal file
58
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
[Русский](ARCHITECTURE.ru.md)
|
||||||
|
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
A telemetry CSV becomes a Markdown report through a small linear pipeline. Everything is stdlib Python; there is no framework and no global state.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
telemetry/
|
||||||
|
__main__.py CLI entry: arg parsing, mode dispatch, stdout. No args -> GUI.
|
||||||
|
parser.py CSV -> Lap (frozen dataclass: session, track, setup, ch, n).
|
||||||
|
segments.py Lap -> corners & straights. Zone detection + numbering.
|
||||||
|
corner_metrics.py Per-zone number crunching: braking point, lock/spin, coast, temps.
|
||||||
|
resample.py Adaptive distance grid + channel interpolation for the trace.
|
||||||
|
report_common.py Header block, legend strings, md_table, token_estimate, atomic write.
|
||||||
|
report_technique.py Driving report (corners, straights, cost, trace).
|
||||||
|
report_setup.py Car report (setup, balance, tyres, brakes, suspension, phases).
|
||||||
|
report_compare.py Two+ laps side by side, per-corner and cumulative delta.
|
||||||
|
report_race.py A folder of laps: lap times, stints, wear, thermals, ERS.
|
||||||
|
rename.py Insert lap number into filenames; skip already-processed.
|
||||||
|
gui.py tkinter window; runs generation/rename on a worker thread.
|
||||||
|
prompts/ Russian LLM prompts, one per report mode.
|
||||||
|
tests/ pytest suite + fixture laps.
|
||||||
|
```
|
||||||
|
|
||||||
|
## How a CSV becomes a report
|
||||||
|
|
||||||
|
1. **`__main__.main`** parses `<mode> <files…>`, validates arity (technique/setup = 1 file, compare = 2+, race = 1 dir), and dispatches.
|
||||||
|
2. **`parser.load_lap`** reads the file as UTF-8 and splits it into four meta blocks (session, track, setup) plus the 108-channel telemetry table. Channel names are normalised by stripping the ` [unit]` suffix. Every column that is entirely zero is dropped — the game writes many dead channels (see Gotchas).
|
||||||
|
3. **`segments.detect_corners`** walks the samples, marks each as "cornering" when `|Steer| > STEER_ON` or `Brake > BRAKE_ON`, merges adjacent zones closer than `MERGE_GAP_M`, drops zones shorter than `MIN_LEN_M`, and numbers what's left T1..Tn by distance. For each zone it calls **`corner_metrics.corner_from_zone`**, which extracts the braking point, apex, gear, full-throttle point, peak brake/gLat, relative lock%/spin%, coast distance, and rear tyre temperature peak. `straights` fills the gaps between corners.
|
||||||
|
4. **`resample.adaptive_points`** builds a distance grid that is dense inside (and on approach to) corners and sparse on straights — the step sizes default to the lap's own median sample spacing via `auto_steps`, so denser raw telemetry yields a denser trace. **`sample_at`** interpolates each channel onto that grid (nearest-sample for discrete channels like Gear/DRS).
|
||||||
|
5. The **`report_<mode>.generate`** function assembles Markdown from `report_common` helpers (`header_block`, `md_table`, `legend`) and the data above, then calls **`report_common.write_report`**, which writes atomically (temp file + `os.replace`) and returns `(abs_path, tokens)`.
|
||||||
|
6. **`__main__`** prints that path and `~N tokens (budget 60k)`. The **GUI** shows the same tuple in its log instead of printing.
|
||||||
|
|
||||||
|
## Adding things
|
||||||
|
|
||||||
|
**A new report mode.** Create `telemetry/report_x.py` with `generate(lap, out_path) -> tuple[str, int]` (or `generate(laps, out_path)` for multi-lap). Build the text from `report_common` helpers and end with `return write_report(out_path, text)`. In `__main__.py` add `'x'` to the mode choices and a dispatch branch that computes the output path (`report_path(input, 'x')`) and calls your `generate`. Add the mode to the GUI's radio list and `_worker_generate` if you want it in the UI.
|
||||||
|
|
||||||
|
**A new trace channel.** Sample it in the report's trace builder via `sample_at(lap, 'ChannelName', dists)`, add it to the trace header and `resample.format_trace_row`, and add a `LEGEND_*` string in `report_common.py` so the column is documented.
|
||||||
|
|
||||||
|
**A new corner metric.** Add a field to the `Corner` dataclass in `segments.py` and compute it in `corner_metrics.corner_from_zone`. Because `Corner` is frozen, set it through the constructor.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
**Files are UTF-8, the OS default here is cp1251.** `parser.load_lap` always passes `encoding='utf-8'`; opening a telemetry file with the default codec raises `UnicodeDecodeError` on the first non-ASCII byte. `rename.read_lap_number` reads only the first five lines but must use the same encoding.
|
||||||
|
|
||||||
|
**Many channels are all-zero in this game version** — `Torque`, `IcePower`, ride heights, suspension loads, the per-section tyre temps, `Clutch`, `Handbrake`, and more. The parser drops any column that is entirely zero, so downstream code must treat a missing channel as "not recorded", not "error".
|
||||||
|
|
||||||
|
**`WheelSlip*` is a km/h speed difference, not a ratio.** A front value of −4.5 under braking is normal. Lock/spin are therefore computed as *relative* slip (`slip / max(speed, 30) * 100`) with thresholds at −10 % / +8 %; raw slip values are not comparable across speeds, and front-vs-rear baselines differ.
|
||||||
|
|
||||||
|
**`Steer` is a percent (−100..100), not a fraction.** Small values on a straight are still percent.
|
||||||
|
|
||||||
|
**The setup row has a trailing comma** (an empty `FuelLoad` tail field in some exports). The parser drops trailing empties so the setup dict has no blank-string key.
|
||||||
|
|
||||||
|
**Rename idempotency hinges on the `L<digits>` token.** `lap_token_present` is the single source of truth for "already processed". Changing the token format means changing both `insert_lap_token` and `lap_token_present` together, or rename stops being safe to re-run.
|
||||||
|
|
||||||
|
**Reports go next to the input, not next to the program.** `report_path` derives the `reports/` directory from the *input CSV's* folder; `race` mode computes it from the race folder's parent. Moving the tool does not move where reports land.
|
||||||
58
ARCHITECTURE.ru.md
Normal file
58
ARCHITECTURE.ru.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
[English](ARCHITECTURE.md)
|
||||||
|
|
||||||
|
# Архитектура
|
||||||
|
|
||||||
|
CSV-телеметрия превращается в 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, атомарная запись.
|
||||||
|
report_technique.py Отчёт о пилотировании (повороты, прямые, расход, трасса).
|
||||||
|
report_setup.py Отчёт о машине (сетап, баланс, шины, тормоза, подвеска, фазы).
|
||||||
|
report_compare.py Два+ круга рядом, дельта по поворотам и накопленная.
|
||||||
|
report_race.py Папка кругов: лаптаймы, стинты, износ, тепло, ERS.
|
||||||
|
rename.py Вставка номера круга в имена файлов; пропуск обработанных.
|
||||||
|
gui.py Окно tkinter; генерация/переименование в рабочем потоке.
|
||||||
|
prompts/ Русские промпты для LLM, по одному на режим.
|
||||||
|
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`** собирает Markdown из помощников `report_common` (`header_block`, `md_table`, `legend`) и данных выше, затем вызывает **`report_common.write_report`**, которая пишет атомарно (временный файл + `os.replace`) и возвращает `(abs_path, tokens)`.
|
||||||
|
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, задавай через конструктор.
|
||||||
|
|
||||||
|
## Подводные камни
|
||||||
|
|
||||||
|
**Файлы в 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 держится на токене `L<цифры>`.** `lap_token_present` — единственный источник истины для «уже обработан». Смена формата токена требует менять `insert_lap_token` и `lap_token_present` вместе, иначе rename перестаёт быть безопасным для повторного запуска.
|
||||||
|
|
||||||
|
**Отчёты выходят рядом с входом, а не рядом с программой.** `report_path` выводит папку `reports/` из папки *входного CSV*; режим `race` считает её от родителя папки гонки. Перемещение инструмента не меняет, куда попадают отчёты.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Andrew Pimenov
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
97
README.md
Normal file
97
README.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
[Русский](README.ru.md)
|
||||||
|
|
||||||
|
# F1 Telemetry Analyzer
|
||||||
|
|
||||||
|
Turns raw F1 22 lap telemetry into compact Markdown reports you can paste into an LLM for analysis. A full raw lap is ~1M tokens; these reports are 1–35k.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the tool/ folder
|
||||||
|
python -m telemetry technique ../zandvoort_P_L7_74.074_ferrari.csv
|
||||||
|
# -> ../reports/zandvoort_P_L7_74.074_ferrari_technique.md (~34k tokens)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or just double-click `run-gui.bat` and click **Generate report**.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
- Python 3.10+ (uses only the standard library — no `pip install`).
|
||||||
|
- Tk is required for the GUI; it ships with the standard Python installer on Windows/macOS. The CLI works without it.
|
||||||
|
|
||||||
|
Download or clone the repository. Keep your telemetry CSVs in the parent folder of `tool/` (see [folder layout](#folder-layout)).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Five modes. Four produce reports, one renames files.
|
||||||
|
|
||||||
|
| Mode | Input | Use it to analyse |
|
||||||
|
|------|-------|-------------------|
|
||||||
|
| `technique` | one lap | **how** you drive — braking points, apex speeds, throttle application, lockups, resource cost |
|
||||||
|
| `setup` | one lap | **what** is set — balance (US/OS), tyre temps, brakes, suspension rake & roll |
|
||||||
|
| `compare` | two+ laps | **where** one lap is faster — per-corner and cumulative delta |
|
||||||
|
| `race` | a folder of laps | the whole race — pace, stint degradation, tyre wear, ERS per lap |
|
||||||
|
| `rename` | files/folder | insert the lap number into each filename (`..._74.074_...` → `..._L7_74.074_...`) |
|
||||||
|
|
||||||
|
Reports include a legend explaining every column, so the LLM understands the data without extra prompting. Ready-made analysis prompts live in [`prompts/`](prompts/) — one per mode.
|
||||||
|
|
||||||
|
### Folder layout
|
||||||
|
|
||||||
|
The program lives in `tool/`; your telemetry sits one level up, output lands next to it:
|
||||||
|
|
||||||
|
```
|
||||||
|
Telemetry/
|
||||||
|
zandvoort_P_L7_74.074_ferrari.csv <- raw laps here
|
||||||
|
races/
|
||||||
|
Zandvoort_Race_2026/ <- one folder per race (for `race` mode)
|
||||||
|
reports/ <- generated reports land here
|
||||||
|
tool/ <- this repository
|
||||||
|
telemetry/
|
||||||
|
prompts/
|
||||||
|
run-gui.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handy things
|
||||||
|
|
||||||
|
**GUI** — double-click `run-gui.bat` (or `run-gui.pyw` for no console window, or run `python -m telemetry gui`). Pick a mode, browse to the file/folder, click Generate. The **Rename unprocessed** button adds lap numbers to files that don't have them yet and leaves the rest alone.
|
||||||
|
|
||||||
|
**CLI** — from the `tool/` folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m telemetry technique ../lap.csv
|
||||||
|
python -m telemetry setup ../lap.csv
|
||||||
|
python -m telemetry compare ../lap_a.csv ../lap_b.csv
|
||||||
|
python -m telemetry race ../races/Zandvoort_Race_2026
|
||||||
|
python -m telemetry rename ../races/Zandvoort_Race_2026 # or individual files
|
||||||
|
```
|
||||||
|
|
||||||
|
Every report run prints the output path and a token estimate:
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\...\Telemetry\reports\lap_technique.md
|
||||||
|
~33834 tokens (budget 60k)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rename only touches unprocessed files.** A file is "processed" once its name contains an `L<number>` token, so re-running rename is safe and idempotent.
|
||||||
|
|
||||||
|
## Input format
|
||||||
|
|
||||||
|
F1 22 UDP telemetry exported to CSV (tested with [F1Laps](https://www.f1laps.com)). UTF-8, ~1800 rows per lap (≈29 Hz), 108 channels. One file = one lap. For `race` mode, drop all of a race's lap files into one folder.
|
||||||
|
|
||||||
|
## For developers
|
||||||
|
|
||||||
|
Pure-stdlib Python. Pipeline: `parser` → `segments` (+ `corner_metrics`) → `resample` → `report_*`. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tool
|
||||||
|
python -m pytest -q # run the test suite
|
||||||
|
python -m telemetry gui # launch the GUI
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding a mode: drop a `telemetry/report_<mode>.py` exposing `generate(...) -> (abs_path, tokens)` and wire one line into `telemetry/__main__.py`. The parser, corner detection, adaptive trace, and shared report blocks are reused.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Built for analysing personal F1 22 hotlaps. Telemetry export format from [F1Laps](https://www.f1laps.com).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
97
README.ru.md
Normal file
97
README.ru.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
[English](README.md)
|
||||||
|
|
||||||
|
# F1 Telemetry Analyzer
|
||||||
|
|
||||||
|
Превращает сырую телеметрию круга F1 22 в компактные Markdown-отчёты для анализа через LLM. Сырой круг — около 1М токенов; отчёты — 1–35k.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# из папки tool/
|
||||||
|
python -m telemetry technique ../zandvoort_P_L7_74.074_ferrari.csv
|
||||||
|
# -> ../reports/zandvoort_P_L7_74.074_ferrari_technique.md (~34k токенов)
|
||||||
|
```
|
||||||
|
|
||||||
|
Или просто двойной клик по `run-gui.bat` и кнопка **Generate report**.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
- Python 3.10+ (только стандартная библиотека — без `pip install`).
|
||||||
|
- Для GUI нужен Tk; он идёт в стандартном установщике Python на Windows/macOS. CLI работает и без него.
|
||||||
|
|
||||||
|
Скачай или склонируй репозиторий. Держи CSV-файлы телеметрии в папке на уровень выше `tool/` (см. [структуру папок](#структура-папок)).
|
||||||
|
|
||||||
|
## Что умеет
|
||||||
|
|
||||||
|
Пять режимов. Четыре строят отчёты, один переименовывает файлы.
|
||||||
|
|
||||||
|
| Режим | Вход | Для анализа |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `technique` | один круг | **как** едешь — точки торможения, скорости в апексах, работа газом, блокировки, расход ресурсов |
|
||||||
|
| `setup` | один круг | **что** настроено — баланс (US/OS), температуры шин, тормоза, rake и крен подвески |
|
||||||
|
| `compare` | два+ круга | **где** один круг быстрее — дельта по поворотам и накопленная |
|
||||||
|
| `race` | папка кругов | вся гонка — темп, деградация в стинтах, износ резины, ERS по кругам |
|
||||||
|
| `rename` | файлы/папка | вставить номер круга в имя файла (`..._74.074_...` → `..._L7_74.074_...`) |
|
||||||
|
|
||||||
|
Отчёты содержат легенду с пояснением каждой колонки — LLM понимает данные без дополнительных подсказок. Готовые промпты для анализа лежат в [`prompts/`](prompts/) — по одному на режим.
|
||||||
|
|
||||||
|
### Структура папок
|
||||||
|
|
||||||
|
Программа живёт в `tool/`; телеметрия — на уровень выше, отчёты выходят рядом с ней:
|
||||||
|
|
||||||
|
```
|
||||||
|
Telemetry/
|
||||||
|
zandvoort_P_L7_74.074_ferrari.csv <- сырые круги здесь
|
||||||
|
races/
|
||||||
|
Zandvoort_Race_2026/ <- по папке на гонку (для режима `race`)
|
||||||
|
reports/ <- сюда выходят отчёты
|
||||||
|
tool/ <- этот репозиторий
|
||||||
|
telemetry/
|
||||||
|
prompts/
|
||||||
|
run-gui.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Полезное
|
||||||
|
|
||||||
|
**GUI** — двойной клик по `run-gui.bat` (или `run-gui.pyw` без окна консоли, либо `python -m telemetry gui`). Выбери режим, укажи файл/папку, нажми Generate. Кнопка **Rename unprocessed** добавляет номера круга к файлам, у которых их ещё нет, и не трогает остальные.
|
||||||
|
|
||||||
|
**CLI** — из папки `tool/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m telemetry technique ../lap.csv
|
||||||
|
python -m telemetry setup ../lap.csv
|
||||||
|
python -m telemetry compare ../lap_a.csv ../lap_b.csv
|
||||||
|
python -m telemetry race ../races/Zandvoort_Race_2026
|
||||||
|
python -m telemetry rename ../races/Zandvoort_Race_2026 # или отдельные файлы
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый прогон печатает путь к отчёту и оценку токенов:
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\...\Telemetry\reports\lap_technique.md
|
||||||
|
~33834 tokens (budget 60k)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rename трогает только необработанные файлы.** Файл считается обработанным, как только в имени появляется токен `L<номер>` — поэтому повторный запуск безопасен и идемпотентен.
|
||||||
|
|
||||||
|
## Входной формат
|
||||||
|
|
||||||
|
Телеметрия F1 22 (UDP), экспортированная в CSV (тестировалось на [F1Laps](https://www.f1laps.com)). UTF-8, ~1800 строк на круг (≈29 Гц), 108 каналов. Один файл = один круг. Для режима `race` сложи все круги гонки в одну папку.
|
||||||
|
|
||||||
|
## Для разработчиков
|
||||||
|
|
||||||
|
Чистый Python на стандартной библиотеке. Конвейер: `parser` → `segments` (+ `corner_metrics`) → `resample` → `report_*`. См. [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tool
|
||||||
|
python -m pytest -q # прогнать тесты
|
||||||
|
python -m telemetry gui # запустить GUI
|
||||||
|
```
|
||||||
|
|
||||||
|
Новый режим: добавь `telemetry/report_<mode>.py` с функцией `generate(...) -> (abs_path, tokens)` и одну строку в `telemetry/__main__.py`. Парсер, детекция поворотов, адаптивная трасса и общие блоки отчётов переиспользуются.
|
||||||
|
|
||||||
|
## Благодарности
|
||||||
|
|
||||||
|
Сделано для разбора личных хотлапов в F1 22. Формат экспорта телеметрии — [F1Laps](https://www.f1laps.com).
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT — см. [LICENSE](LICENSE).
|
||||||
64
prompts/compare.md
Normal file
64
prompts/compare.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
Ты — гоночный инженер, сравнивающий два круга одного пилота. Первый круг (Lap 1) — эталонный. Найди, где и почему один круг быстрее другого, и что это говорит о пилотировании.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
**Шапка** — сравнительная таблица: laptime, секторы S1/S2/S3, шина, топливо на старте круга, износ шин, погода.
|
||||||
|
|
||||||
|
**Corners** — по каждому повороту:
|
||||||
|
- `Vmin L1 / Vmin L2` — минимальная скорость в апексе [км/ч]
|
||||||
|
- `time L1 / time L2` — время в зоне поворота [с]
|
||||||
|
- `Δt seg` — разница времён в данном повороте (L2 − L1): отрицательное = L2 быстрее, положительное = L1 быстрее
|
||||||
|
- `Δt cum` — накопленная дельта к концу этого поворота: показывает общий счёт на данном участке круга
|
||||||
|
|
||||||
|
**Delta trace** — построчно по дистанции:
|
||||||
|
- `dist` — дистанция [м]
|
||||||
|
- `spd_L1 / spd_L2` — скорость каждого круга [км/ч]
|
||||||
|
- `Δt` — накопленная дельта L2 − L1 [с]: отрицательное = L2 ведёт, положительное = L1 ведёт
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Разбери каждый пункт. Каждый вывод подкрепляй конкретными числами Δt, дистанциями, скоростями.
|
||||||
|
|
||||||
|
### 1. Общая картина
|
||||||
|
|
||||||
|
- Итоговая разница времён и в каком секторе она сформировалась
|
||||||
|
- Был ли разрыв стабильным по кругу или резко возник в одном месте
|
||||||
|
- Влияние топлива и износа шин: если круги из разных стинтов — учти поправку на загрузку и деградацию
|
||||||
|
|
||||||
|
### 2. Разбор по поворотам
|
||||||
|
|
||||||
|
По каждому повороту с ненулевым `Δt seg`:
|
||||||
|
|
||||||
|
- Кто быстрее и на сколько
|
||||||
|
- `Vmin L1 vs L2`: кто выше скорость в апексе — тот либо позже тормозил, либо лучше нашёл траекторию
|
||||||
|
- Если Vmin выше, но `time` больше — апекс быстрее, но выход хуже (потеря на тяге или траектории)
|
||||||
|
- Если Vmin ниже, но `time` меньше — лучший выход компенсировал потерю в апексе
|
||||||
|
- `Δt cum` после поворота: нарастает ли отрыв или следующий участок его нивелирует
|
||||||
|
|
||||||
|
### 3. Анализ дельта-трассы
|
||||||
|
|
||||||
|
Работай с `Δt` по дистанции:
|
||||||
|
|
||||||
|
- **Где дельта строится резко** (крутой излом): это критические точки — торможение или тяга, где стили расходятся
|
||||||
|
- **Где дельта стабильна**: оба круга едут одинаково, эти зоны не приоритет
|
||||||
|
- **Тормозные зоны**: если Δt уменьшается при торможении — L2 тормозит позже (или эффективнее)
|
||||||
|
- **Тяговые зоны**: если после апекса `spd_L2 > spd_L1` — L2 лучше тянет (раньше газ или меньше буксовки)
|
||||||
|
- **Прямые**: разница скоростей на прямой = результат предыдущего выхода из поворота, а не самой прямой
|
||||||
|
|
||||||
|
### 4. Паттерны
|
||||||
|
|
||||||
|
- Один круг систематически лучше в медленных поворотах, другой — в быстрых?
|
||||||
|
- Один пилот (или стиль) рискует на входах, другой — на выходах?
|
||||||
|
- Есть ли повороты, где «медленный» круг на самом деле быстрее в этом конкретном месте — и почему?
|
||||||
|
|
||||||
|
### 5. Итог и приоритеты
|
||||||
|
|
||||||
|
- **Что взять из быстрого круга**: конкретные повороты и техника
|
||||||
|
- **Приоритетные зоны для работы**: где наибольший потенциал (самые большие `|Δt seg|`)
|
||||||
|
- **Что не копировать**: если в «быстром» круге есть места, где он всё же медленнее — зафиксируй
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ниже прикреплён отчёт сравнения. Начинай анализ.
|
||||||
62
prompts/en/compare.md
Normal file
62
prompts/en/compare.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
You are a race engineer comparing two laps from the same driver in F1 22. Lap 1 is the reference. Find where and why one lap is faster than the other and what it reveals about the driving.
|
||||||
|
|
||||||
|
## Data format
|
||||||
|
|
||||||
|
**Header** — comparison table: laptime, sectors S1/S2/S3, tyre, fuel at lap start, tyre wear, weather.
|
||||||
|
|
||||||
|
**Corners** — per corner:
|
||||||
|
- `Vmin L1 / Vmin L2` — minimum apex speed [km/h]
|
||||||
|
- `time L1 / time L2` — time in corner zone [s]
|
||||||
|
- `Δt seg` — time delta in this corner (L2 − L1): negative = L2 faster, positive = L1 faster
|
||||||
|
- `Δt cum` — cumulative delta at end of this corner: shows running score to this point in the lap
|
||||||
|
|
||||||
|
**Delta trace** — per sample by distance:
|
||||||
|
- `dist` — distance [m]
|
||||||
|
- `spd_L1 / spd_L2` — speed per lap [km/h]
|
||||||
|
- `Δt` — cumulative delta L2 − L1 [s]: negative = L2 leads, positive = L1 leads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Analyse each point. Back every conclusion with specific Δt numbers, distances, speeds.
|
||||||
|
|
||||||
|
### 1. Overview
|
||||||
|
|
||||||
|
- Overall time difference and which sector it was built in
|
||||||
|
- Was the gap steady across the lap or created sharply in one area
|
||||||
|
- Fuel and tyre wear effect: if laps are from different stints, account for load and degradation
|
||||||
|
|
||||||
|
### 2. Corner-by-corner
|
||||||
|
|
||||||
|
Per corner with non-zero `Δt seg`:
|
||||||
|
- Who is faster and by how much
|
||||||
|
- `Vmin L1 vs L2`: higher apex speed = either later braking or better line
|
||||||
|
- High Vmin but higher `time` = faster apex but worse exit (traction or trajectory loss)
|
||||||
|
- Lower Vmin but lower `time` = better exit compensating for the slower apex
|
||||||
|
- `Δt cum` after corner: does the gap grow, or does the next section recover it
|
||||||
|
|
||||||
|
### 3. Delta trace analysis
|
||||||
|
|
||||||
|
Working with `Δt` by distance:
|
||||||
|
- **Where delta builds sharply** (steep kink): critical points — braking or traction where styles diverge
|
||||||
|
- **Where delta is flat**: both laps equivalent, lower priority zones
|
||||||
|
- **Braking zones**: Δt shrinking under braking = L2 brakes later (or more effectively)
|
||||||
|
- **Traction zones**: after apex `spd_L2 > spd_L1` = L2 traction better (earlier throttle or less wheelspin)
|
||||||
|
- **Straights**: speed difference on a straight = result of the preceding corner exit, not the straight itself
|
||||||
|
|
||||||
|
### 4. Patterns
|
||||||
|
|
||||||
|
- One lap systematically better in slow corners, the other in fast?
|
||||||
|
- One style risks entries, the other risks exits?
|
||||||
|
- Any corner where the "slower" lap is actually faster at that specific point — and why?
|
||||||
|
|
||||||
|
### 5. Summary and priorities
|
||||||
|
|
||||||
|
- **What to take from the faster lap**: specific corners and technique
|
||||||
|
- **Priority zones to work on**: biggest `|Δt seg|` opportunities
|
||||||
|
- **What not to copy**: corners where the "faster" lap is still slower than the reference — note them
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Below is the attached comparison report. Begin analysis.
|
||||||
96
prompts/en/race.md
Normal file
96
prompts/en/race.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
You are a race engineer analysing full-race telemetry in F1 22. The report covers all laps: lap times, stints, tyre wear, thermals, and ERS. Perform a detailed race analysis.
|
||||||
|
|
||||||
|
## Data format
|
||||||
|
|
||||||
|
**Lap times** — per lap:
|
||||||
|
- `Lap` — lap number
|
||||||
|
- `Time` — lap time `M:SS.mmm`
|
||||||
|
- `S1 / S2 / S3` — sector times [s]
|
||||||
|
- `Tyre` — compound (C1–C5, I, W)
|
||||||
|
- `Age` — laps on this set (1 = first lap on fresh set)
|
||||||
|
- `Fuel kg` — fuel at lap start [kg]
|
||||||
|
- `Note` — flags: `FAST` = fastest valid lap, `PIT` = in-lap, `INV` = invalidated
|
||||||
|
|
||||||
|
**Stint summary** — per stint:
|
||||||
|
- `Stint` — stint number
|
||||||
|
- `Laps` — laps in stint
|
||||||
|
- `Tyre` — compound
|
||||||
|
- `Laps on tyre` — total laps on this set
|
||||||
|
- `Avg pace` — average pace (excluding pit laps and invalidated)
|
||||||
|
- `Best / Worst` — best and worst lap in stint
|
||||||
|
- `Deg s/lap` — degradation slope per tyre age lap (positive = slowing, negative = improving)
|
||||||
|
|
||||||
|
**Tyre wear per lap** — `FL / FR / RL / RR` — wear % at end of each lap
|
||||||
|
|
||||||
|
**Thermal summary per lap**:
|
||||||
|
- `Tf avg°C` — average peak front tyre temperature for the lap
|
||||||
|
- `Tr avg°C` — rear
|
||||||
|
- `Brk FL max°C` — peak front-left brake temperature
|
||||||
|
- `Brk RR max°C` — peak rear-right brake temperature
|
||||||
|
|
||||||
|
**ERS per lap**:
|
||||||
|
- `ERS MJ` — total energy from ERS over the lap (cumulative deployed at lap end)
|
||||||
|
- `MGU-K MJ` — MGU-K recovery
|
||||||
|
- `MGU-H MJ` — MGU-H recovery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Analyse each point. Back every conclusion with specific numbers from the tables.
|
||||||
|
|
||||||
|
### 1. Race overview
|
||||||
|
|
||||||
|
- How did pace evolve through the race: progression or degradation?
|
||||||
|
- Were there outliers (abnormally slow or fast laps) — name the lap numbers and reason (PIT, INV, safety car?)
|
||||||
|
- Compare best vs average pace: how large is the gap?
|
||||||
|
- One-sentence overall race assessment
|
||||||
|
|
||||||
|
### 2. Stint analysis
|
||||||
|
|
||||||
|
Per stint from `Stint summary`:
|
||||||
|
- Average pace and relative quality (best/worst stint?)
|
||||||
|
- `Best–Worst` spread: large spread = inconsistent pace, small = consistent
|
||||||
|
- Compare stints: where was the driver faster and why (tyre, fuel, track condition?)
|
||||||
|
- Was the pit stop timed well: not too early/late given degradation trend
|
||||||
|
|
||||||
|
### 3. Tyre degradation
|
||||||
|
|
||||||
|
From `Deg s/lap` and `Tyre wear`:
|
||||||
|
- Which stint has the most/least degradation?
|
||||||
|
- Compare `Deg s/lap` vs actual wear increment per lap — consistent?
|
||||||
|
- When did the tyre "die": identify the lap where pace dropped sharply
|
||||||
|
- Wear evenness: FL/FR or RL/RR asymmetry?
|
||||||
|
|
||||||
|
### 4. Fuel effect
|
||||||
|
|
||||||
|
- For the first (or long) stint: correlate `Fuel kg` and `Time` per lap
|
||||||
|
- How much does fuel effect explain early-stint pace improvement? (typical ≈ 0.03–0.05 s/kg)
|
||||||
|
- If early-stint pace is poor — tyre warm-up or fuel load?
|
||||||
|
- Were early-stint laps too slow relative to the fuel correction
|
||||||
|
|
||||||
|
### 5. Thermal management
|
||||||
|
|
||||||
|
From `Thermal summary`:
|
||||||
|
- How do tyre temps evolve through each stint: warm-up, stable, overheating?
|
||||||
|
- Signs of overheating (sharp `Tf` or `Tr` rise with simultaneous pace drop)?
|
||||||
|
- Brake temps: rising through the race distance or stable? FL vs RR asymmetry?
|
||||||
|
- Which laps had critical thermal conditions?
|
||||||
|
|
||||||
|
### 6. Best and worst laps
|
||||||
|
|
||||||
|
- `FAST` lap: which stint, what tyre age, what fuel load?
|
||||||
|
- Why did the best lap happen when it did — track rubber, late braking, cool air?
|
||||||
|
- Worst valid lap (not INV, not PIT): reason — degradation, traffic, driver error?
|
||||||
|
- Patterns: "out lap" always slow, "in lap" pace dip?
|
||||||
|
|
||||||
|
### 7. Summary and recommendations
|
||||||
|
|
||||||
|
- **What worked**: strategy and driving that was executed well
|
||||||
|
- **Main losses**: specific laps and zones where the most time was lost
|
||||||
|
- **Strategic recommendations**: pit timing, compound choice, stint length
|
||||||
|
- **Driving technique**: if degradation is abnormal — what to change in driving style
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Below is the attached race report. Begin analysis.
|
||||||
91
prompts/en/setup.md
Normal file
91
prompts/en/setup.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
You are an experienced F1 22 race engineer. The following is a car setup report for one lap. Perform a detailed setup analysis: what is configured, how it affects car behaviour, what should be changed.
|
||||||
|
|
||||||
|
## Data format
|
||||||
|
|
||||||
|
**Setup** — full parameter table:
|
||||||
|
- `FWing / RWing` — front/rear wing angle [0–50]
|
||||||
|
- `OnThrottle / OffThrottle` — differential lock on/off throttle [%]
|
||||||
|
- `FrontCamber / RearCamber` — front/rear wheel camber [°], negative = tilted inward
|
||||||
|
- `FrontToe / RearToe` — front/rear toe [°]
|
||||||
|
- `FrontSusp / RearSusp` — spring stiffness [1–11]
|
||||||
|
- `FrontAntiRoll / RearAntiRoll` — anti-roll bar stiffness [1–11]
|
||||||
|
- `FrontSuspH / RearSuspH` — ride height [mm]
|
||||||
|
- `BrakePressure` — maximum brake system pressure [%]
|
||||||
|
- `BrakeBias` — front/rear brake balance [%], >50 = forward bias
|
||||||
|
- `FLTyrePressure / FRTyrePressure / RLTyrePressure / RRTyrePressure` — cold tyre pressure [PSI]
|
||||||
|
- `Ballast` — ballast
|
||||||
|
- `FuelLoad` — fuel at start
|
||||||
|
|
||||||
|
**Corner balance** — per corner:
|
||||||
|
- `gLat_max` — peak lateral G-force [G]
|
||||||
|
- `steer/g` — steering angle to lateral G ratio: high = steering a lot for the G = understeer
|
||||||
|
- `front|slip|` / `rear|slip|` — average absolute slip of front/rear axle in the corner zone
|
||||||
|
- `balance` — outcome: US (front axle slips more), OS (rear), — (neutral)
|
||||||
|
|
||||||
|
**Tyres** — per wheel:
|
||||||
|
- `wearΔ%` — wear over the lap
|
||||||
|
- `surf min/avg/max °C` — surface temperature
|
||||||
|
- `carc min/avg/max °C` — carcass temperature
|
||||||
|
- `pressure` — hot pressure [PSI]
|
||||||
|
|
||||||
|
**Brakes** — min/avg/max temperature per wheel [°C]
|
||||||
|
|
||||||
|
**Suspension**:
|
||||||
|
- Rake — average front vs rear ride height [mm]; positive rake = nose higher than tail
|
||||||
|
- Roll per corner — body roll in each corner [mm]; shows outer-wheel loading
|
||||||
|
|
||||||
|
**Lap phases** — average tyre surface temperature across three equal lap segments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Analyse each point. Back every conclusion with numbers from the report.
|
||||||
|
|
||||||
|
### 1. Aerodynamic balance
|
||||||
|
|
||||||
|
- FWing/RWing ratio: front/rear downforce balance
|
||||||
|
- How high is the downforce level for this track type (many slow corners = more needed, high-speed = can reduce drag)
|
||||||
|
- Any potential to cut drag without hurting balance
|
||||||
|
|
||||||
|
### 2. Mechanical balance
|
||||||
|
|
||||||
|
- Corner balance: systematic US or OS across most corners?
|
||||||
|
- Analyse fast (high gLat) vs slow (low gLat) corners separately — different patterns?
|
||||||
|
- Link imbalance to specific settings: F/R spring stiffness, anti-roll bars, camber, toe — most likely cause
|
||||||
|
- Differential (OnThrottle/OffThrottle): effect on exits — high OnThrottle = more traction but harder to rotate on exit
|
||||||
|
|
||||||
|
### 3. Tyre thermal window
|
||||||
|
|
||||||
|
- Optimal operating temperature for dry tyres ≈ 80–110°C surface
|
||||||
|
- Are tyres in window; who overheats, who underheats
|
||||||
|
- Surface vs carcass delta: large gap = tyre overheating on the outside but cold carcass (delamination risk), or vice versa
|
||||||
|
- Wear: even across 4 wheels — abnormally high wear on one wheel signals load imbalance
|
||||||
|
- Lap phases: normal warm-up pattern — should rise in phase 1 and stabilise
|
||||||
|
|
||||||
|
### 4. Tyre pressures
|
||||||
|
|
||||||
|
- Hot pressure (from Tyres) vs cold (from Setup): delta shows how much the tyre heated
|
||||||
|
- Too-high hot pressure = overheating; too-low = under-temperature
|
||||||
|
- F/R pressure balance: asymmetry affects aero and mechanical balance
|
||||||
|
|
||||||
|
### 5. Brakes
|
||||||
|
|
||||||
|
- BrakeBias: evaluate against temperatures — if rears are hotter, bias is rearward; if fronts, forward
|
||||||
|
- Temperature range: <200°C = cold (poor braking), 400–900°C = normal, >1000°C = overheating
|
||||||
|
- L/R asymmetry: different temperatures on same axle = uneven pad wear or balance issue
|
||||||
|
|
||||||
|
### 6. Suspension
|
||||||
|
|
||||||
|
- Rake: positive = nose higher than tail — standard for downforce; very large = risk of nose "sticking" at speed
|
||||||
|
- F/R stiffness: stiffer front = OS tendency; stiffer rear = US
|
||||||
|
- Anti-roll bars: high values = less body roll but more sensitive to bumps; link to Roll per corner
|
||||||
|
- Roll per corner: typical roll 3–6 mm; large roll = soft suspension
|
||||||
|
|
||||||
|
### 7. Recommendations
|
||||||
|
|
||||||
|
Per identified issue — what specifically to change (parameter, direction) and expected effect. Prioritise by lap time impact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Below is the attached report. Begin analysis.
|
||||||
91
prompts/en/technique.md
Normal file
91
prompts/en/technique.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
You are an experienced racing engineer. The following is single-lap telemetry from F1 22. Perform a detailed driving technique analysis.
|
||||||
|
|
||||||
|
## Data format
|
||||||
|
|
||||||
|
The report contains:
|
||||||
|
|
||||||
|
**Corners** — corner table. Per corner:
|
||||||
|
- `brake@m` — distance at which braking starts from lap start [m]
|
||||||
|
- `Ventry / Vmin / Vexit` — entry / apex / exit speed [km/h]
|
||||||
|
- `apex@m` — distance of the minimum-speed point
|
||||||
|
- `gear` — gear at apex
|
||||||
|
- `fullThr@m` — distance of first sustained full throttle after apex
|
||||||
|
- `maxBrake%` — peak brake pressure
|
||||||
|
- `gLat` — peak lateral G-force [G]
|
||||||
|
- `time s` — time in the corner zone
|
||||||
|
- `lock%` — peak relative front wheel slip under braking (negative; −10% or below = LOCK flag)
|
||||||
|
- `spin%` — peak relative rear wheel slip on throttle (positive; 8%+ = SPIN flag)
|
||||||
|
- `coast m` — distance with neither brake nor full throttle between braking and throttle application; 0 = abrupt transition
|
||||||
|
- `Tr peak °C` — peak rear tyre temperature in the zone
|
||||||
|
|
||||||
|
**Straights** — Vmax, DRS, time.
|
||||||
|
|
||||||
|
**Driving cost** — whole lap: fuel used, per-tyre wear, ERS (spent/harvested), peak brake temperatures.
|
||||||
|
|
||||||
|
**Trace** — per-sample telemetry by distance:
|
||||||
|
`dist` `time` `spd` `thr` `brk` `steer` `gear` `drs` `gLat` `gLon` `fuel` `ers` `Tf` `Tr` `rpm` `slipF` `slipR` `yaw`
|
||||||
|
|
||||||
|
Key columns:
|
||||||
|
- `slipF` — front relative wheel slip (% of speed): negative under braking = locking tendency
|
||||||
|
- `slipR` — rear relative slip: positive on throttle = wheelspin
|
||||||
|
- `yaw` — yaw rate [rad/s]: large `steer` + small `yaw` = car not rotating (understeer)
|
||||||
|
- `Tf / Tr` — average front/rear tyre surface temperature [°C]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Analyse each point below. Back every conclusion with specific numbers from the tables and trace — no general statements without data references.
|
||||||
|
|
||||||
|
### 1. Braking
|
||||||
|
|
||||||
|
Per corner with non-zero `brake@m`:
|
||||||
|
- Evaluate the braking point and how fully the brakes are used (`maxBrake%`)
|
||||||
|
- Analyse `lock%`: how severe is the lockup, isolated or repeated across corners
|
||||||
|
- Evaluate `coast m`: long coast = cautious transition or trail braking; zero = snap release
|
||||||
|
- Compare `Ventry` and `Vmin`: large delta = hard braking deep into the corner; small = braking complete before turn-in
|
||||||
|
|
||||||
|
### 2. Apexes
|
||||||
|
|
||||||
|
Per corner:
|
||||||
|
- `Vmin` in context of the corner's speed class — over-braked or on the limit?
|
||||||
|
- `gear` at apex — too low (excess torque, wheelspin risk on exit)?
|
||||||
|
- `gLat` vs `Vmin`: high gLat at low Vmin = good apex loading; low gLat = missed apex
|
||||||
|
|
||||||
|
### 3. Exits
|
||||||
|
|
||||||
|
Per corner:
|
||||||
|
- `fullThr@m` vs `apex@m`: smaller gap = more aggressive exit
|
||||||
|
- `spin%`: high value = wheelspin on exit — throttle applied too early or too aggressively
|
||||||
|
- `Vexit`: final result — does it match the corner's speed potential
|
||||||
|
- From Trace: how `thr` builds after apex — stepped or smooth; any drops after reaching full throttle
|
||||||
|
|
||||||
|
### 4. Steering and handling
|
||||||
|
|
||||||
|
From Trace (focus on cornering zones):
|
||||||
|
- `steer`: any sharp sawtooth inputs or excessive corrections
|
||||||
|
- `steer` + `yaw`: zones where yaw lags far behind steer — specific understeer points
|
||||||
|
- Corners where the car refuses to rotate (high steer, weak yaw response)
|
||||||
|
|
||||||
|
### 5. ERS management
|
||||||
|
|
||||||
|
From Trace (`ers` — cumulative MJ deployed):
|
||||||
|
- Where ERS is used most heavily — straights or corner exits
|
||||||
|
- How much of the lap budget is consumed (compare with Driving cost)
|
||||||
|
- Recovery balance (MGU-K vs MGU-H)
|
||||||
|
|
||||||
|
### 6. Thermal management
|
||||||
|
|
||||||
|
- `Tf / Tr` from Trace: how temperatures evolve through the lap, where peaks occur, any overheating signs
|
||||||
|
- `Tr peak` per corner: which corners load the rear tyres hardest
|
||||||
|
- Brake temps from Driving cost: overheating (>1000°C is critical), L/R or F/R asymmetry
|
||||||
|
|
||||||
|
### 7. Summary
|
||||||
|
|
||||||
|
- **Top-3 time-loss zones** with specific corners and root causes
|
||||||
|
- **Top-3 strengths** — what is done well and why
|
||||||
|
- **Recommendations** per problem area: what specifically to change in driving technique
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Below is the attached report. Begin analysis.
|
||||||
99
prompts/race.md
Normal file
99
prompts/race.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
Ты — гоночный инженер, анализирующий телеметрию целой гонки. Перед тобой отчёт по всем кругам: времена, стинты, износ шин, тепловой режим и ERS. Проведи детальный разбор гонки.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
Отчёт содержит:
|
||||||
|
|
||||||
|
**Lap times** — таблица кругов:
|
||||||
|
- `Lap` — номер круга
|
||||||
|
- `Time` — время круга в формате `M:SS.mmm`
|
||||||
|
- `S1 / S2 / S3` — времена секторов [с]
|
||||||
|
- `Tyre` — состав шины (C1–C5, I, W)
|
||||||
|
- `Age` — количество кругов на данном комплекте (тирейдж, 1 = первый круг на новом комплекте)
|
||||||
|
- `Fuel kg` — топливо на старте круга [кг]
|
||||||
|
- `Note` — флаги: `FAST` = быстрейший действительный круг, `PIT` = пит-лэп, `INV` = недействительный круг
|
||||||
|
|
||||||
|
**Stint summary** — сводка по стинтам:
|
||||||
|
- `Stint` — номер стинта
|
||||||
|
- `Laps` — количество кругов в стинте
|
||||||
|
- `Tyre` — состав
|
||||||
|
- `Laps on tyre` — всего кругов на комплекте
|
||||||
|
- `Avg pace` — средний темп (без пит-лэпов и недействительных)
|
||||||
|
- `Best / Worst` — лучший и худший круг стинта
|
||||||
|
- `Deg s/lap` — деградация: наклон тренда времён по тирейджу (положительное = замедление, отрицательное = улучшение)
|
||||||
|
|
||||||
|
**Tyre wear per lap** — износ по кругам:
|
||||||
|
- `FL / FR / RL / RR` — процент износа каждой шины (последний замер круга)
|
||||||
|
|
||||||
|
**Thermal summary per lap** — тепловой режим:
|
||||||
|
- `Tf avg°C` — средняя максимальная температура передних шин за круг
|
||||||
|
- `Tr avg°C` — задних шин
|
||||||
|
- `Brk FL max°C` — максимальная температура переднего левого тормоза
|
||||||
|
- `Brk RR max°C` — заднего правого тормоза
|
||||||
|
|
||||||
|
**ERS per lap** — энергетика:
|
||||||
|
- `ERS MJ` — суммарная энергия из ERS за круг (накопленный расход в конце круга)
|
||||||
|
- `MGU-K MJ` — рекуперация MGU-K
|
||||||
|
- `MGU-H MJ` — рекуперация MGU-H
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Разбери каждый из пунктов. Каждый вывод подкрепляй конкретными числами из таблиц — не делай общих утверждений без ссылки на данные.
|
||||||
|
|
||||||
|
### 1. Общая картина гонки
|
||||||
|
|
||||||
|
- Как менялся темп по ходу гонки: прогрессия или деградация?
|
||||||
|
- Были ли выбросы (аномально медленные или быстрые круги) — назови номера кругов и причину (PIT, INV, safety car?)
|
||||||
|
- Сравни лучший и средний темп: насколько велик разрыв?
|
||||||
|
- Итоговая оценка гонки в одном предложении
|
||||||
|
|
||||||
|
### 2. Анализ стинтов
|
||||||
|
|
||||||
|
По каждому стинту из `Stint summary`:
|
||||||
|
- Средний темп и его относительное качество (лучший / худший стинт?)
|
||||||
|
- Разброс `Best–Worst`: большой разброс = непостоянный темп, малый = стабильный
|
||||||
|
- Сравни стинты между собой: где пилот ехал быстрее и почему (шина, топливо, трек?)
|
||||||
|
- Был ли питстоп своевременным: не слишком ли рано/поздно по тренду деградации?
|
||||||
|
|
||||||
|
### 3. Деградация резины
|
||||||
|
|
||||||
|
По `Deg s/lap` и таблице `Tyre wear`:
|
||||||
|
- В каком стинте деградация наибольшая и наименьшая?
|
||||||
|
- Сопоставь `Deg s/lap` с фактическим приростом износа по кругам — соответствуют ли друг другу?
|
||||||
|
- Когда резина «умерла»: определи круг, после которого темп резко упал
|
||||||
|
- Равномерность износа: есть ли асимметрия FL/FR или RL/RR?
|
||||||
|
|
||||||
|
### 4. Топливный эффект
|
||||||
|
|
||||||
|
- По первому стинту (или длинному стинту): сопоставь `Fuel kg` и `Time` по кругам
|
||||||
|
- Насколько топливный эффект объясняет улучшение темпа в начале стинта? (типичная оценка: ~0.03–0.05 с/кг)
|
||||||
|
- Если темп в начале стинта плохой — не шина, а топливо или прогрев?
|
||||||
|
- Оцени, были ли круги в начале стинта слишком медленными относительно топливной поправки
|
||||||
|
|
||||||
|
### 5. Тепловой менеджмент
|
||||||
|
|
||||||
|
По `Thermal summary`:
|
||||||
|
- Как меняются температуры шин по кругам в каждом стинте: прогрев, стабилизация, перегрев?
|
||||||
|
- Есть ли признаки перегрева (резкий рост `Tf` или `Tr` при одновременном ухудшении темпа)?
|
||||||
|
- Температуры тормозов: рост по дистанции или стабильные? Асимметрия FL vs RR?
|
||||||
|
- В каких кругах тепловой режим был критическим?
|
||||||
|
|
||||||
|
### 6. Лучшие и худшие круги
|
||||||
|
|
||||||
|
- `FAST` круг: в каком стинте, на каком тирейдже, при каком расходе топлива?
|
||||||
|
- Почему лучший круг получился лучшим — трек rubber? позднее торможение? холодный воздух?
|
||||||
|
- Худший действительный круг (не INV, не PIT): причина — деградация, трафик, ошибка?
|
||||||
|
- Есть ли интересные паттерны: «out lap» всегда медленный, «in lap» провальный по темпу?
|
||||||
|
|
||||||
|
### 7. Итог и рекомендации
|
||||||
|
|
||||||
|
- **Что сработало:** что в стратегии и пилотировании было сделано хорошо
|
||||||
|
- **Главные потери:** конкретные круги и зоны, где потеряно больше всего
|
||||||
|
- **Стратегические рекомендации:** момент питстопа, выбор состава, длина стинтов
|
||||||
|
- **Техника пилотирования:** если деградация аномальна — что нужно изменить в стиле езды
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ниже прикреплён отчёт гонки. Начинай анализ.
|
||||||
64
prompts/ru/compare.md
Normal file
64
prompts/ru/compare.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
Ты — гоночный инженер, сравнивающий два круга одного пилота в F1 22. Первый круг (Lap 1) — эталонный. Найди, где и почему один круг быстрее другого, и что это говорит о пилотировании.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
**Шапка** — сравнительная таблица: laptime, секторы S1/S2/S3, шина, топливо на старте круга, износ шин, погода.
|
||||||
|
|
||||||
|
**Corners** — по каждому повороту:
|
||||||
|
- `Vmin L1 / Vmin L2` — минимальная скорость в апексе [км/ч]
|
||||||
|
- `time L1 / time L2` — время в зоне поворота [с]
|
||||||
|
- `Δt seg` — разница времён в данном повороте (L2 − L1): отрицательное = L2 быстрее, положительное = L1 быстрее
|
||||||
|
- `Δt cum` — накопленная дельта к концу этого поворота: показывает общий счёт на данном участке круга
|
||||||
|
|
||||||
|
**Delta trace** — построчно по дистанции:
|
||||||
|
- `dist` — дистанция [м]
|
||||||
|
- `spd_L1 / spd_L2` — скорость каждого круга [км/ч]
|
||||||
|
- `Δt` — накопленная дельта L2 − L1 [с]: отрицательное = L2 ведёт, положительное = L1 ведёт
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Разбери каждый пункт. Каждый вывод подкрепляй конкретными числами Δt, дистанциями, скоростями.
|
||||||
|
|
||||||
|
### 1. Общая картина
|
||||||
|
|
||||||
|
- Итоговая разница времён и в каком секторе она сформировалась
|
||||||
|
- Был ли разрыв стабильным по кругу или резко возник в одном месте
|
||||||
|
- Влияние топлива и износа шин: если круги из разных стинтов — учти поправку на загрузку и деградацию
|
||||||
|
|
||||||
|
### 2. Разбор по поворотам
|
||||||
|
|
||||||
|
По каждому повороту с ненулевым `Δt seg`:
|
||||||
|
|
||||||
|
- Кто быстрее и на сколько
|
||||||
|
- `Vmin L1 vs L2`: кто выше скорость в апексе — тот либо позже тормозил, либо лучше нашёл траекторию
|
||||||
|
- Если Vmin выше, но `time` больше — апекс быстрее, но выход хуже (потеря на тяге или траектории)
|
||||||
|
- Если Vmin ниже, но `time` меньше — лучший выход компенсировал потерю в апексе
|
||||||
|
- `Δt cum` после поворота: нарастает ли отрыв или следующий участок его нивелирует
|
||||||
|
|
||||||
|
### 3. Анализ дельта-трассы
|
||||||
|
|
||||||
|
Работай с `Δt` по дистанции:
|
||||||
|
|
||||||
|
- **Где дельта строится резко** (крутой излом): это критические точки — торможение или тяга, где стили расходятся
|
||||||
|
- **Где дельта стабильна**: оба круга едут одинаково, эти зоны не приоритет
|
||||||
|
- **Тормозные зоны**: если Δt уменьшается при торможении — L2 тормозит позже (или эффективнее)
|
||||||
|
- **Тяговые зоны**: если после апекса `spd_L2 > spd_L1` — L2 лучше тянет (раньше газ или меньше буксовки)
|
||||||
|
- **Прямые**: разница скоростей на прямой = результат предыдущего выхода из поворота, а не самой прямой
|
||||||
|
|
||||||
|
### 4. Паттерны
|
||||||
|
|
||||||
|
- Один круг систематически лучше в медленных поворотах, другой — в быстрых?
|
||||||
|
- Один пилот (или стиль) рискует на входах, другой — на выходах?
|
||||||
|
- Есть ли повороты, где «медленный» круг на самом деле быстрее в этом конкретном месте — и почему?
|
||||||
|
|
||||||
|
### 5. Итог и приоритеты
|
||||||
|
|
||||||
|
- **Что взять из быстрого круга**: конкретные повороты и техника
|
||||||
|
- **Приоритетные зоны для работы**: где наибольший потенциал (самые большие `|Δt seg|`)
|
||||||
|
- **Что не копировать**: если в «быстром» круге есть места, где он всё же медленнее — зафиксируй
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ниже прикреплён отчёт сравнения. Начинай анализ.
|
||||||
99
prompts/ru/race.md
Normal file
99
prompts/ru/race.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
Ты — гоночный инженер, анализирующий телеметрию целой гонки в F1 22. Перед тобой отчёт по всем кругам: времена, стинты, износ шин, тепловой режим и ERS. Проведи детальный разбор гонки.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
Отчёт содержит:
|
||||||
|
|
||||||
|
**Lap times** — таблица кругов:
|
||||||
|
- `Lap` — номер круга
|
||||||
|
- `Time` — время круга в формате `M:SS.mmm`
|
||||||
|
- `S1 / S2 / S3` — времена секторов [с]
|
||||||
|
- `Tyre` — состав шины (C1–C5, I, W)
|
||||||
|
- `Age` — количество кругов на данном комплекте (тирейдж, 1 = первый круг на новом комплекте)
|
||||||
|
- `Fuel kg` — топливо на старте круга [кг]
|
||||||
|
- `Note` — флаги: `FAST` = быстрейший действительный круг, `PIT` = пит-лэп, `INV` = недействительный круг
|
||||||
|
|
||||||
|
**Stint summary** — сводка по стинтам:
|
||||||
|
- `Stint` — номер стинта
|
||||||
|
- `Laps` — количество кругов в стинте
|
||||||
|
- `Tyre` — состав
|
||||||
|
- `Laps on tyre` — всего кругов на комплекте
|
||||||
|
- `Avg pace` — средний темп (без пит-лэпов и недействительных)
|
||||||
|
- `Best / Worst` — лучший и худший круг стинта
|
||||||
|
- `Deg s/lap` — деградация: наклон тренда времён по тирейджу (положительное = замедление, отрицательное = улучшение)
|
||||||
|
|
||||||
|
**Tyre wear per lap** — износ по кругам:
|
||||||
|
- `FL / FR / RL / RR` — процент износа каждой шины (последний замер круга)
|
||||||
|
|
||||||
|
**Thermal summary per lap** — тепловой режим:
|
||||||
|
- `Tf avg°C` — средняя максимальная температура передних шин за круг
|
||||||
|
- `Tr avg°C` — задних шин
|
||||||
|
- `Brk FL max°C` — максимальная температура переднего левого тормоза
|
||||||
|
- `Brk RR max°C` — заднего правого тормоза
|
||||||
|
|
||||||
|
**ERS per lap** — энергетика:
|
||||||
|
- `ERS MJ` — суммарная энергия из ERS за круг (накопленный расход в конце круга)
|
||||||
|
- `MGU-K MJ` — рекуперация MGU-K
|
||||||
|
- `MGU-H MJ` — рекуперация MGU-H
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Разбери каждый из пунктов. Каждый вывод подкрепляй конкретными числами из таблиц — не делай общих утверждений без ссылки на данные.
|
||||||
|
|
||||||
|
### 1. Общая картина гонки
|
||||||
|
|
||||||
|
- Как менялся темп по ходу гонки: прогрессия или деградация?
|
||||||
|
- Были ли выбросы (аномально медленные или быстрые круги) — назови номера кругов и причину (PIT, INV, safety car?)
|
||||||
|
- Сравни лучший и средний темп: насколько велик разрыв?
|
||||||
|
- Итоговая оценка гонки в одном предложении
|
||||||
|
|
||||||
|
### 2. Анализ стинтов
|
||||||
|
|
||||||
|
По каждому стинту из `Stint summary`:
|
||||||
|
- Средний темп и его относительное качество (лучший / худший стинт?)
|
||||||
|
- Разброс `Best–Worst`: большой разброс = непостоянный темп, малый = стабильный
|
||||||
|
- Сравни стинты между собой: где пилот ехал быстрее и почему (шина, топливо, трек?)
|
||||||
|
- Был ли питстоп своевременным: не слишком ли рано/поздно по тренду деградации?
|
||||||
|
|
||||||
|
### 3. Деградация резины
|
||||||
|
|
||||||
|
По `Deg s/lap` и таблице `Tyre wear`:
|
||||||
|
- В каком стинте деградация наибольшая и наименьшая?
|
||||||
|
- Сопоставь `Deg s/lap` с фактическим приростом износа по кругам — соответствуют ли друг другу?
|
||||||
|
- Когда резина «умерла»: определи круг, после которого темп резко упал
|
||||||
|
- Равномерность износа: есть ли асимметрия FL/FR или RL/RR?
|
||||||
|
|
||||||
|
### 4. Топливный эффект
|
||||||
|
|
||||||
|
- По первому стинту (или длинному стинту): сопоставь `Fuel kg` и `Time` по кругам
|
||||||
|
- Насколько топливный эффект объясняет улучшение темпа в начале стинта? (типичная оценка: ~0.03–0.05 с/кг)
|
||||||
|
- Если темп в начале стинта плохой — не шина, а топливо или прогрев?
|
||||||
|
- Оцени, были ли круги в начале стинта слишком медленными относительно топливной поправки
|
||||||
|
|
||||||
|
### 5. Тепловой менеджмент
|
||||||
|
|
||||||
|
По `Thermal summary`:
|
||||||
|
- Как меняются температуры шин по кругам в каждом стинте: прогрев, стабилизация, перегрев?
|
||||||
|
- Есть ли признаки перегрева (резкий рост `Tf` или `Tr` при одновременном ухудшении темпа)?
|
||||||
|
- Температуры тормозов: рост по дистанции или стабильные? Асимметрия FL vs RR?
|
||||||
|
- В каких кругах тепловой режим был критическим?
|
||||||
|
|
||||||
|
### 6. Лучшие и худшие круги
|
||||||
|
|
||||||
|
- `FAST` круг: в каком стинте, на каком тирейдже, при каком расходе топлива?
|
||||||
|
- Почему лучший круг получился лучшим — трек rubber? позднее торможение? холодный воздух?
|
||||||
|
- Худший действительный круг (не INV, не PIT): причина — деградация, трафик, ошибка?
|
||||||
|
- Есть ли интересные паттерны: «out lap» всегда медленный, «in lap» провальный по темпу?
|
||||||
|
|
||||||
|
### 7. Итог и рекомендации
|
||||||
|
|
||||||
|
- **Что сработало:** что в стратегии и пилотировании было сделано хорошо
|
||||||
|
- **Главные потери:** конкретные круги и зоны, где потеряно больше всего
|
||||||
|
- **Стратегические рекомендации:** момент питстопа, выбор состава, длина стинтов
|
||||||
|
- **Техника пилотирования:** если деградация аномальна — что нужно изменить в стиле езды
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ниже прикреплён отчёт гонки. Начинай анализ.
|
||||||
91
prompts/ru/setup.md
Normal file
91
prompts/ru/setup.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
Ты — опытный гоночный инженер. Перед тобой отчёт по настройкам машины за один круг в F1 22. Проведи детальный разбор сетапа: что настроено, как это влияет на поведение машины, что стоит изменить.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
**Setup** — полная таблица параметров:
|
||||||
|
- `FWing / RWing` — угол переднего / заднего антикрыла [0–50]
|
||||||
|
- `OnThrottle / OffThrottle` — блокировка дифференциала на газу / без газа [%]
|
||||||
|
- `FrontCamber / RearCamber` — развал передних / задних колёс [°], отрицательный = наклон внутрь
|
||||||
|
- `FrontToe / RearToe` — схождение передних / задних колёс [°]
|
||||||
|
- `FrontSusp / RearSusp` — жёсткость пружин подвески [1–11]
|
||||||
|
- `FrontAntiRoll / RearAntiRoll` — жёсткость стабилизатора поперечной устойчивости [1–11]
|
||||||
|
- `FrontSuspH / RearSuspH` — высота подвески (ride height) [мм]
|
||||||
|
- `BrakePressure` — максимальное давление в тормозной системе [%]
|
||||||
|
- `BrakeBias` — баланс тормозного усилия перед/зад [%], >50 = смещение вперёд
|
||||||
|
- `FLTyrePressure / FRTyrePressure / RLTyrePressure / RRTyrePressure` — давление шин холодное [PSI]
|
||||||
|
- `Ballast` — балласт
|
||||||
|
- `FuelLoad` — топливо на старт
|
||||||
|
|
||||||
|
**Corner balance** — по каждому повороту:
|
||||||
|
- `gLat_max` — пиковая боковая перегрузка [G]
|
||||||
|
- `steer/g` — отношение угла руля к боковой G: высокое = рулишь много на данном G = understeer
|
||||||
|
- `front|slip|` / `rear|slip|` — средний модуль скольжения переднего / заднего осей в зоне поворота
|
||||||
|
- `balance` — итог: US (передняя ось проскальзывает сильнее), OS (задняя), — (нейтрально)
|
||||||
|
|
||||||
|
**Tyres** — по каждому колесу:
|
||||||
|
- `wearΔ%` — износ за круг
|
||||||
|
- `surf min/avg/max °C` — температура поверхности
|
||||||
|
- `carc min/avg/max °C` — температура каркаса
|
||||||
|
- `pressure` — горячее давление [PSI]
|
||||||
|
|
||||||
|
**Brakes** — температура мин/сред/макс по каждому колесу [°C]
|
||||||
|
|
||||||
|
**Suspension**:
|
||||||
|
- Rake — средняя высота носа vs кормы [мм]; положительный rake = нос выше кормы
|
||||||
|
- Roll по поворотам — крен кузова в каждом повороте [мм]; показывает загрузку внешних колёс
|
||||||
|
|
||||||
|
**Lap phases** — средняя температура поверхности шин по трём равным отрезкам круга
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Разбери каждый пункт. Каждый вывод подкрепляй числами из отчёта.
|
||||||
|
|
||||||
|
### 1. Аэродинамический баланс
|
||||||
|
|
||||||
|
- Соотношение FWing/RWing: баланс прижимной силы F/R
|
||||||
|
- Насколько высокая прижимная сила относительно типа трассы (много медленных поворотов = нужно больше, высокоскоростные = можно снизить сопротивление)
|
||||||
|
- Есть ли потенциал для снижения лобового сопротивления без ущерба балансу
|
||||||
|
|
||||||
|
### 2. Механический баланс
|
||||||
|
|
||||||
|
- Corner balance: есть ли систематический US или OS по большинству поворотов
|
||||||
|
- Отдельно рассмотри быстрые (высокий gLat) vs медленные (низкий gLat) повороты — разные ли паттерны
|
||||||
|
- Вяжи дисбаланс с конкретными настройками: жёсткость F/R подвески, антикрены, развал, схождение — что наиболее вероятная причина
|
||||||
|
- Дифференциал (OnThrottle/OffThrottle): как влияет на выходы — высокий OnThrottle = больше тяги, но труднее управлять при развороте
|
||||||
|
|
||||||
|
### 3. Тепловой режим шин
|
||||||
|
|
||||||
|
- Оптимальное окно рабочей температуры для сухих шин — примерно 80–110°C поверхности
|
||||||
|
- Попадают ли шины в рабочий диапазон; кто перегревается, кто недогревается
|
||||||
|
- Разница поверхности и каркаса: большая разница = шина перегревается снаружи, но каркас холодный (риск расслоения), или наоборот
|
||||||
|
- Износ: равномерный ли по 4 колёсам — аномально высокий износ одного колеса указывает на дисбаланс нагрузки
|
||||||
|
- Lap phases: нормальный ли прогрев — должен расти в первой фазе и стабилизироваться
|
||||||
|
|
||||||
|
### 4. Давление шин
|
||||||
|
|
||||||
|
- Горячее давление (из Tyres) vs холодное (из Setup): дельта показывает, насколько шина нагрелась
|
||||||
|
- Слишком высокое горячее давление = шина перегревается; слишком низкое = недогрев
|
||||||
|
- Баланс F/R давления: асимметрия влияет на аэро- и механический баланс
|
||||||
|
|
||||||
|
### 5. Тормоза
|
||||||
|
|
||||||
|
- BrakeBias: оцени в контексте температур — если задние тормоза горячее, bias смещён назад; если передние — вперёд
|
||||||
|
- Диапазон температур: <200°C = холодные (плохое торможение), 400–900°C = норма, >1000°C = перегрев
|
||||||
|
- Асимметрия L/R: разные температуры на одной оси = неравномерный износ колодок или проблема с балансом
|
||||||
|
|
||||||
|
### 6. Подвеска
|
||||||
|
|
||||||
|
- Rake: положительный = нос выше кормы — стандарт для прижимной силы; если очень большой — риск "прилипания" носа на скорости
|
||||||
|
- Жёсткость F/R: более жёсткая передняя = склонность к OS; более жёсткая задняя = US
|
||||||
|
- Антикрены: высокие значения = машина меньше кренится, но чувствительнее к буграм; связывай с Roll в поворотах
|
||||||
|
- Roll по поворотам: нормальный крен 3–6 мм; большой крен = мягкая подвеска
|
||||||
|
|
||||||
|
### 7. Рекомендации
|
||||||
|
|
||||||
|
По каждой выявленной проблеме — что конкретно менять (параметр, направление изменения) и каков ожидаемый эффект. Приоритизируй по влиянию на время круга.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ниже прикреплён отчёт. Начинай анализ.
|
||||||
91
prompts/ru/technique.md
Normal file
91
prompts/ru/technique.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
Ты — опытный инженер-аналитик гоночной команды. Перед тобой телеметрия одного круга в F1 22. Проведи максимально детальный разбор стиля пилотирования.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
Отчёт содержит:
|
||||||
|
|
||||||
|
**Corners** — таблица поворотов. По каждому:
|
||||||
|
- `brake@m` — дистанция начала торможения от старта круга
|
||||||
|
- `Ventry / Vmin / Vexit` — скорость входа / апекса / выхода [км/ч]
|
||||||
|
- `apex@m` — дистанция точки минимальной скорости
|
||||||
|
- `gear` — передача в апексе
|
||||||
|
- `fullThr@m` — дистанция первого устойчивого полного газа после апекса
|
||||||
|
- `maxBrake%` — пиковое давление на тормоз
|
||||||
|
- `gLat` — пиковая боковая перегрузка [G]
|
||||||
|
- `time s` — время в зоне поворота
|
||||||
|
- `lock%` — пик относительного скольжения передних колёс при торможении (отрицательное; −10% и ниже = флаг LOCK)
|
||||||
|
- `spin%` — пик относительного скольжения задних колёс на газу (положительное; 8%+ = флаг SPIN)
|
||||||
|
- `coast m` — дистанция без тормоза и без полного газа между торможением и тягой; 0 = резкий переход
|
||||||
|
- `Tr peak °C` — пиковая температура задней шины в зоне
|
||||||
|
|
||||||
|
**Straights** — прямые: Vmax, DRS, время.
|
||||||
|
|
||||||
|
**Driving cost** — за весь круг: расход топлива, износ каждой шины, ERS (потрачено / рекуперировано), пиковые температуры тормозов.
|
||||||
|
|
||||||
|
**Trace** — построчная телеметрия по дистанции:
|
||||||
|
`dist` `time` `spd` `thr` `brk` `steer` `gear` `drs` `gLat` `gLon` `fuel` `ers` `Tf` `Tr` `rpm` `slipF` `slipR` `yaw`
|
||||||
|
|
||||||
|
Пояснения к ключевым колонкам:
|
||||||
|
- `slipF` — относительное скольжение передних колёс (% от скорости): уход в минус при торможении = блокировка
|
||||||
|
- `slipR` — задних колёс: положительный на газу = пробуксовка
|
||||||
|
- `yaw` — угловая скорость рыскания [рад/с]: большой `steer` + маленький `yaw` = машина не поворачивает (understeer)
|
||||||
|
- `Tf / Tr` — средняя температура передних / задних шин [°C]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Разбери каждый из пунктов ниже. Каждый вывод подкрепляй конкретными числами из таблиц и трассы — не делай общих утверждений без ссылки на данные.
|
||||||
|
|
||||||
|
### 1. Торможения
|
||||||
|
|
||||||
|
По каждому повороту с ненулевым `brake@m`:
|
||||||
|
- Оцени точку торможения и полноту использования тормозов (`maxBrake%`)
|
||||||
|
- Проанализируй `lock%`: насколько сильная блокировка, стабильна ли она или разовая
|
||||||
|
- Оцени `coast m`: длинный накат — осторожный переход или trail braking; нулевой — резкий перенос ноги
|
||||||
|
- Сравни `Ventry` и `Vmin`: большая разница = активное торможение в повороте; малая = торможение завершено до поворота
|
||||||
|
|
||||||
|
### 2. Апексы
|
||||||
|
|
||||||
|
По каждому повороту:
|
||||||
|
- `Vmin` в контексте скоростного класса поворота — перетормозил или точно?
|
||||||
|
- `gear` в апексе — не слишком ли низкая передача (лишний момент, риск буксовки на выходе)
|
||||||
|
- `gLat` vs `Vmin`: высокий gLat при низком Vmin = хорошая загрузка в апексе; низкий gLat = промах мимо вершины
|
||||||
|
|
||||||
|
### 3. Выходы
|
||||||
|
|
||||||
|
По каждому повороту:
|
||||||
|
- `fullThr@m` относительно `apex@m`: чем меньше разрыв, тем агрессивнее выход
|
||||||
|
- `spin%`: высокое значение = буксовка на выходе — слишком рано или слишком резко газ
|
||||||
|
- `Vexit`: итоговый результат выхода — соответствует ли скоростному потенциалу поворота
|
||||||
|
- По Trace: как нарастает `thr` после апекса — ступенчато или плавно; есть ли просадки после полного газа
|
||||||
|
|
||||||
|
### 4. Работа руля и поворачиваемость
|
||||||
|
|
||||||
|
По Trace (фокус на зоны поворотов):
|
||||||
|
- `steer`: нет ли резких пилообразных движений или избыточных коррекций
|
||||||
|
- `steer` + `yaw`: в каких зонах yaw сильно отстаёт от steer — конкретные точки understeer
|
||||||
|
- В каких поворотах машина отказывается поворачивать (большой steer, слабый yaw)
|
||||||
|
|
||||||
|
### 5. ERS-менеджмент
|
||||||
|
|
||||||
|
По Trace (`ers` — накопленный расход в МДж):
|
||||||
|
- Где ERS расходуется активнее — прямые или выходы из поворотов
|
||||||
|
- Полнота использования запаса за круг (сравни с Driving cost)
|
||||||
|
- Темп рекуперации (MGU-K vs MGU-H): баланс
|
||||||
|
|
||||||
|
### 6. Тепловой режим
|
||||||
|
|
||||||
|
- `Tf / Tr` по Trace: как меняется температура по кругу, где пики, есть ли перегрев
|
||||||
|
- `Tr peak` по поворотам: какие повороты наиболее нагружают задние шины
|
||||||
|
- Brake temps из Driving cost: перегрев (>1000°C критично), асимметрия L/R или F/R
|
||||||
|
|
||||||
|
### 7. Итог
|
||||||
|
|
||||||
|
- **Топ-3 зоны потери времени** с конкретными поворотами и причинами
|
||||||
|
- **Топ-3 сильные стороны** — что делается хорошо и почему
|
||||||
|
- **Рекомендации** по каждой проблемной зоне: что конкретно изменить в технике пилотирования
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ниже прикреплён отчёт. Начинай анализ.
|
||||||
91
prompts/setup.md
Normal file
91
prompts/setup.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
Ты — опытный гоночный инженер F1. Перед тобой отчёт по настройкам машины за один круг. Проведи детальный разбор сетапа: что настроено, как это влияет на поведение машины, что стоит изменить.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
**Setup** — полная таблица параметров:
|
||||||
|
- `FWing / RWing` — угол переднего / заднего антикрыла [0–50]
|
||||||
|
- `OnThrottle / OffThrottle` — блокировка дифференциала на газу / без газа [%]
|
||||||
|
- `FrontCamber / RearCamber` — развал передних / задних колёс [°], отрицательный = наклон внутрь
|
||||||
|
- `FrontToe / RearToe` — схождение передних / задних колёс [°]
|
||||||
|
- `FrontSusp / RearSusp` — жёсткость пружин подвески [1–11]
|
||||||
|
- `FrontAntiRoll / RearAntiRoll` — жёсткость стабилизатора поперечной устойчивости [1–11]
|
||||||
|
- `FrontSuspH / RearSuspH` — высота подвески (ride height) [мм]
|
||||||
|
- `BrakePressure` — максимальное давление в тормозной системе [%]
|
||||||
|
- `BrakeBias` — баланс тормозного усилия перед/зад [%], >50 = смещение вперёд
|
||||||
|
- `FLTyrePressure / FRTyrePressure / RLTyrePressure / RRTyrePressure` — давление шин холодное [PSI]
|
||||||
|
- `Ballast` — балласт
|
||||||
|
- `FuelLoad` — топливо на старт
|
||||||
|
|
||||||
|
**Corner balance** — по каждому повороту:
|
||||||
|
- `gLat_max` — пиковая боковая перегрузка [G]
|
||||||
|
- `steer/g` — отношение угла руля к боковой G: высокое = рулишь много на данном G = understeer
|
||||||
|
- `front|slip|` / `rear|slip|` — средний модуль скольжения переднего / заднего осей в зоне поворота
|
||||||
|
- `balance` — итог: US (передняя ось проскальзывает сильнее), OS (задняя), — (нейтрально)
|
||||||
|
|
||||||
|
**Tyres** — по каждому колесу:
|
||||||
|
- `wearΔ%` — износ за круг
|
||||||
|
- `surf min/avg/max °C` — температура поверхности
|
||||||
|
- `carc min/avg/max °C` — температура каркаса
|
||||||
|
- `pressure` — горячее давление [PSI]
|
||||||
|
|
||||||
|
**Brakes** — температура мин/сред/макс по каждому колесу [°C]
|
||||||
|
|
||||||
|
**Suspension**:
|
||||||
|
- Rake — средняя высота носа vs кормы [мм]; положительный rake = нос выше кормы
|
||||||
|
- Roll по поворотам — крен кузова в каждом повороте [мм]; показывает загрузку внешних колёс
|
||||||
|
|
||||||
|
**Lap phases** — средняя температура поверхности шин по трём равным отрезкам круга
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Разбери каждый пункт. Каждый вывод подкрепляй числами из отчёта.
|
||||||
|
|
||||||
|
### 1. Аэродинамический баланс
|
||||||
|
|
||||||
|
- Соотношение FWing/RWing: баланс прижимной силы F/R
|
||||||
|
- Насколько высокая прижимная сила относительно типа трассы (много медленных поворотов = нужно больше, высокоскоростные = можно снизить сопротивление)
|
||||||
|
- Есть ли потенциал для снижения лобового сопротивления без ущерба балансу
|
||||||
|
|
||||||
|
### 2. Механический баланс
|
||||||
|
|
||||||
|
- Corner balance: есть ли систематический US или OS по большинству поворотов
|
||||||
|
- Отдельно рассмотри быстрые (высокий gLat) vs медленные (низкий gLat) повороты — разные ли паттерны
|
||||||
|
- Вяжи дисбаланс с конкретными настройками: жёсткость F/R подвески, антикрены, развал, схождение — что наиболее вероятная причина
|
||||||
|
- Дифференциал (OnThrottle/OffThrottle): как влияет на выходы — высокий OnThrottle = больше тяги, но труднее управлять при развороте
|
||||||
|
|
||||||
|
### 3. Тепловой режим шин
|
||||||
|
|
||||||
|
- Оптимальное окно рабочей температуры для сухих шин — примерно 80–110°C поверхности
|
||||||
|
- Попадают ли шины в рабочий диапазон; кто перегревается, кто недогревается
|
||||||
|
- Разница поверхности и каркаса: большая разница = шина перегревается снаружи, но каркас холодный (риск расслоения), или наоборот
|
||||||
|
- Износ: равномерный ли по 4 колёсам — аномально высокий износ одного колеса указывает на дисбаланс нагрузки
|
||||||
|
- Lap phases: нормальный ли прогрев — должен расти в первой фазе и стабилизироваться
|
||||||
|
|
||||||
|
### 4. Давление шин
|
||||||
|
|
||||||
|
- Горячее давление (из Tyres) vs холодное (из Setup): дельта показывает, насколько шина нагрелась
|
||||||
|
- Слишком высокое горячее давление = шина перегревается; слишком низкое = недогрев
|
||||||
|
- Баланс F/R давления: асимметрия влияет на аэро- и механический баланс
|
||||||
|
|
||||||
|
### 5. Тормоза
|
||||||
|
|
||||||
|
- BrakeBias: оцени в контексте температур — если задние тормоза горячее, bias смещён назад; если передние — вперёд
|
||||||
|
- Диапазон температур: <200°C = холодные (плохое торможение), 400–900°C = норма, >1000°C = перегрев
|
||||||
|
- Асимметрия L/R: разные температуры на одной оси = неравномерный износ колодок или проблема с балансом
|
||||||
|
|
||||||
|
### 6. Подвеска
|
||||||
|
|
||||||
|
- Rake: положительный = нос выше кормы — стандарт для прижимной силы; если очень большой — риск "прилипания" носа на скорости
|
||||||
|
- Жёсткость F/R: более жёсткая передняя = склонность к OS; более жёсткая задняя = US
|
||||||
|
- Антикрены: высокие значения = машина меньше кренится, но чувствительнее к буграм; связывай с Roll в поворотах
|
||||||
|
- Roll по поворотам: нормальный крен 3–6 мм; большой крен = мягкая подвеска
|
||||||
|
|
||||||
|
### 7. Рекомендации
|
||||||
|
|
||||||
|
По каждой выявленной проблеме — что конкретно менять (параметр, направление изменения) и каков ожидаемый эффект. Приоритизируй по влиянию на время круга.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ниже прикреплён отчёт. Начинай анализ.
|
||||||
91
prompts/technique.md
Normal file
91
prompts/technique.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
Ты — опытный инженер-аналитик гоночной команды. Перед тобой телеметрия одного круга в F1 22. Проведи максимально детальный разбор стиля пилотирования.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
Отчёт содержит:
|
||||||
|
|
||||||
|
**Corners** — таблица поворотов. По каждому:
|
||||||
|
- `brake@m` — дистанция начала торможения от старта круга
|
||||||
|
- `Ventry / Vmin / Vexit` — скорость входа / апекса / выхода [км/ч]
|
||||||
|
- `apex@m` — дистанция точки минимальной скорости
|
||||||
|
- `gear` — передача в апексе
|
||||||
|
- `fullThr@m` — дистанция первого устойчивого полного газа после апекса
|
||||||
|
- `maxBrake%` — пиковое давление на тормоз
|
||||||
|
- `gLat` — пиковая боковая перегрузка [G]
|
||||||
|
- `time s` — время в зоне поворота
|
||||||
|
- `lock%` — пик относительного скольжения передних колёс при торможении (отрицательное; −10% и ниже = флаг LOCK)
|
||||||
|
- `spin%` — пик относительного скольжения задних колёс на газу (положительное; 8%+ = флаг SPIN)
|
||||||
|
- `coast m` — дистанция без тормоза и без полного газа между торможением и тягой; 0 = резкий переход
|
||||||
|
- `Tr peak °C` — пиковая температура задней шины в зоне
|
||||||
|
|
||||||
|
**Straights** — прямые: Vmax, DRS, время.
|
||||||
|
|
||||||
|
**Driving cost** — за весь круг: расход топлива, износ каждой шины, ERS (потрачено / рекуперировано), пиковые температуры тормозов.
|
||||||
|
|
||||||
|
**Trace** — построчная телеметрия по дистанции:
|
||||||
|
`dist` `time` `spd` `thr` `brk` `steer` `gear` `drs` `gLat` `gLon` `fuel` `ers` `Tf` `Tr` `rpm` `slipF` `slipR` `yaw`
|
||||||
|
|
||||||
|
Пояснения к ключевым колонкам:
|
||||||
|
- `slipF` — относительное скольжение передних колёс (% от скорости): уход в минус при торможении = блокировка
|
||||||
|
- `slipR` — задних колёс: положительный на газу = пробуксовка
|
||||||
|
- `yaw` — угловая скорость рыскания [рад/с]: большой `steer` + маленький `yaw` = машина не поворачивает (understeer)
|
||||||
|
- `Tf / Tr` — средняя температура передних / задних шин [°C]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Разбери каждый из пунктов ниже. Каждый вывод подкрепляй конкретными числами из таблиц и трассы — не делай общих утверждений без ссылки на данные.
|
||||||
|
|
||||||
|
### 1. Торможения
|
||||||
|
|
||||||
|
По каждому повороту с ненулевым `brake@m`:
|
||||||
|
- Оцени точку торможения и полноту использования тормозов (`maxBrake%`)
|
||||||
|
- Проанализируй `lock%`: насколько сильная блокировка, стабильна ли она или разовая
|
||||||
|
- Оцени `coast m`: длинный накат — осторожный переход или trail braking; нулевой — резкий перенос ноги
|
||||||
|
- Сравни `Ventry` и `Vmin`: большая разница = активное торможение в повороте; малая = торможение завершено до поворота
|
||||||
|
|
||||||
|
### 2. Апексы
|
||||||
|
|
||||||
|
По каждому повороту:
|
||||||
|
- `Vmin` в контексте скоростного класса поворота — перетормозил или точно?
|
||||||
|
- `gear` в апексе — не слишком ли низкая передача (лишний момент, риск буксовки на выходе)
|
||||||
|
- `gLat` vs `Vmin`: высокий gLat при низком Vmin = хорошая загрузка в апексе; низкий gLat = промах мимо вершины
|
||||||
|
|
||||||
|
### 3. Выходы
|
||||||
|
|
||||||
|
По каждому повороту:
|
||||||
|
- `fullThr@m` относительно `apex@m`: чем меньше разрыв, тем агрессивнее выход
|
||||||
|
- `spin%`: высокое значение = буксовка на выходе — слишком рано или слишком резко газ
|
||||||
|
- `Vexit`: итоговый результат выхода — соответствует ли скоростному потенциалу поворота
|
||||||
|
- По Trace: как нарастает `thr` после апекса — ступенчато или плавно; есть ли просадки после полного газа
|
||||||
|
|
||||||
|
### 4. Работа руля и поворачиваемость
|
||||||
|
|
||||||
|
По Trace (фокус на зоны поворотов):
|
||||||
|
- `steer`: нет ли резких пилообразных движений или избыточных коррекций
|
||||||
|
- `steer` + `yaw`: в каких зонах yaw сильно отстаёт от steer — конкретные точки understeer
|
||||||
|
- В каких поворотах машина отказывается поворачивать (большой steer, слабый yaw)
|
||||||
|
|
||||||
|
### 5. ERS-менеджмент
|
||||||
|
|
||||||
|
По Trace (`ers` — накопленный расход в МДж):
|
||||||
|
- Где ERS расходуется активнее — прямые или выходы из поворотов
|
||||||
|
- Полнота использования запаса за круг (сравни с Driving cost)
|
||||||
|
- Темп рекуперации (MGU-K vs MGU-H): баланс
|
||||||
|
|
||||||
|
### 6. Тепловой режим
|
||||||
|
|
||||||
|
- `Tf / Tr` по Trace: как меняется температура по кругу, где пики, есть ли перегрев
|
||||||
|
- `Tr peak` по поворотам: какие повороты наиболее нагружают задние шины
|
||||||
|
- Brake temps из Driving cost: перегрев (>1000°C критично), асимметрия L/R или F/R
|
||||||
|
|
||||||
|
### 7. Итог
|
||||||
|
|
||||||
|
- **Топ-3 зоны потери времени** с конкретными поворотами и причинами
|
||||||
|
- **Топ-3 сильные стороны** — что делается хорошо и почему
|
||||||
|
- **Рекомендации** по каждой проблемной зоне: что конкретно изменить в технике пилотирования
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ниже прикреплён отчёт. Начинай анализ.
|
||||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
3
run-gui.bat
Normal file
3
run-gui.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
python -m telemetry gui
|
||||||
7
run-gui.pyw
Normal file
7
run-gui.pyw
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from telemetry.gui import main
|
||||||
|
main()
|
||||||
0
telemetry/__init__.py
Normal file
0
telemetry/__init__.py
Normal file
113
telemetry/__main__.py
Normal file
113
telemetry/__main__.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .parser import load_lap
|
||||||
|
from .report_common import report_path
|
||||||
|
from . import report_technique, report_setup, report_compare, report_race
|
||||||
|
from .rename import rename_unprocessed
|
||||||
|
|
||||||
|
|
||||||
|
def _print_result(abs_path: str, tokens: int) -> None:
|
||||||
|
print(abs_path)
|
||||||
|
print(f'~{tokens} tokens (budget 60k)')
|
||||||
|
|
||||||
|
|
||||||
|
def _run_rename(targets: list[str]) -> None:
|
||||||
|
expanded: list[str] = []
|
||||||
|
for t in targets:
|
||||||
|
p = os.path.abspath(t)
|
||||||
|
if os.path.isdir(p):
|
||||||
|
for name in os.listdir(p):
|
||||||
|
if name.lower().endswith('.csv'):
|
||||||
|
expanded.append(os.path.join(p, name))
|
||||||
|
elif p.lower().endswith('.csv'):
|
||||||
|
expanded.append(p)
|
||||||
|
|
||||||
|
if not expanded:
|
||||||
|
print('Error: no CSV targets resolved', file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
results = rename_unprocessed(targets)
|
||||||
|
for r in results:
|
||||||
|
if r.status == 'renamed':
|
||||||
|
print(f'renamed: {r.old} -> {r.new}')
|
||||||
|
elif r.status == 'skipped':
|
||||||
|
print(f'skipped: {r.old} (already has lap number)')
|
||||||
|
else:
|
||||||
|
print(f'error: {r.old} ({r.detail})')
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# no args → GUI
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
from .gui import main as gui_main
|
||||||
|
gui_main()
|
||||||
|
return
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(prog='python -m telemetry')
|
||||||
|
parser.add_argument('mode', choices=['technique', 'setup', 'compare', 'race', 'rename', 'gui'])
|
||||||
|
parser.add_argument('files', nargs='*')
|
||||||
|
parser.add_argument('--lang', choices=['ru', 'en'], default='ru')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
mode: str = args.mode
|
||||||
|
files: list[str] = args.files
|
||||||
|
lang: str = args.lang
|
||||||
|
|
||||||
|
if mode == 'gui':
|
||||||
|
from .gui import main as gui_main
|
||||||
|
gui_main()
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode == 'rename':
|
||||||
|
if not files:
|
||||||
|
print('Error: rename requires at least one file or directory', file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
_run_rename(files)
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode in ('technique', 'setup') and len(files) != 1:
|
||||||
|
print(f'Error: {mode} requires exactly 1 file', file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if mode == 'compare' and len(files) < 2:
|
||||||
|
print('Error: compare requires 2+ files', file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if mode == 'race' and len(files) != 1:
|
||||||
|
print('Error: race requires exactly 1 directory', file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if mode == 'technique':
|
||||||
|
lap = load_lap(files[0])
|
||||||
|
out = report_path(files[0], 'technique')
|
||||||
|
_print_result(*report_technique.generate(lap, out, lang))
|
||||||
|
|
||||||
|
elif mode == 'setup':
|
||||||
|
lap = load_lap(files[0])
|
||||||
|
out = report_path(files[0], 'setup')
|
||||||
|
_print_result(*report_setup.generate(lap, out, lang))
|
||||||
|
|
||||||
|
elif mode == 'compare':
|
||||||
|
laps = [load_lap(f) for f in files]
|
||||||
|
stem_b = os.path.splitext(os.path.basename(files[1]))[0]
|
||||||
|
out = report_path(files[0], 'compare', stem_b)
|
||||||
|
_print_result(*report_compare.generate(laps, out, lang))
|
||||||
|
|
||||||
|
elif mode == 'race':
|
||||||
|
race_dir = os.path.abspath(files[0])
|
||||||
|
csv_files = sorted(
|
||||||
|
f for f in os.listdir(race_dir) if f.lower().endswith('.csv')
|
||||||
|
)
|
||||||
|
if not csv_files:
|
||||||
|
print(f'Error: no CSV files found in {race_dir}', file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
laps = [load_lap(os.path.join(race_dir, f)) for f in csv_files]
|
||||||
|
dirname = os.path.basename(race_dir.rstrip('/\\'))
|
||||||
|
out = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(race_dir)), 'reports', dirname + '_race.md'
|
||||||
|
)
|
||||||
|
_print_result(*report_race.generate(laps, out, lang))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
142
telemetry/corner_metrics.py
Normal file
142
telemetry/corner_metrics.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
from .parser import Lap
|
||||||
|
from .segments import (
|
||||||
|
Corner,
|
||||||
|
BRAKE_MARK, FULL_THROTTLE, LOCKUP_REL_PCT, SPIN_REL_PCT,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SAFE_SPEED_FLOOR = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(lst: list[float], i: int, default: float = 0.0) -> float:
|
||||||
|
return lst[i] if i < len(lst) else default
|
||||||
|
|
||||||
|
|
||||||
|
def corner_from_zone(
|
||||||
|
lap: Lap,
|
||||||
|
ia: int,
|
||||||
|
ib: int,
|
||||||
|
number: int,
|
||||||
|
dist: list[float],
|
||||||
|
speed: list[float],
|
||||||
|
brake: list[float],
|
||||||
|
throttle: list[float],
|
||||||
|
gear: list[float],
|
||||||
|
glat: list[float],
|
||||||
|
laptime: list[float],
|
||||||
|
slip_fl: list[float],
|
||||||
|
slip_fr: list[float],
|
||||||
|
slip_rl: list[float],
|
||||||
|
slip_rr: list[float],
|
||||||
|
tyre_temp_rl: list[float],
|
||||||
|
tyre_temp_rr: list[float],
|
||||||
|
) -> Corner:
|
||||||
|
idxs = list(range(ia, ib + 1))
|
||||||
|
|
||||||
|
spds = [_safe(speed, i) for i in idxs]
|
||||||
|
v_entry = spds[0] if spds else 0.0
|
||||||
|
v_exit = spds[-1] if spds else 0.0
|
||||||
|
v_min = min(spds) if spds else 0.0
|
||||||
|
apex_i = idxs[spds.index(v_min)]
|
||||||
|
apex_m = dist[apex_i]
|
||||||
|
gear_apex = int(_safe(gear, apex_i))
|
||||||
|
|
||||||
|
brake_start_m: float | None = None
|
||||||
|
for i in idxs:
|
||||||
|
if _safe(brake, i) > BRAKE_MARK:
|
||||||
|
brake_start_m = dist[i]
|
||||||
|
break
|
||||||
|
|
||||||
|
brake_vals = [_safe(brake, i) for i in idxs]
|
||||||
|
brake_max = max(brake_vals) if brake_vals else 0.0
|
||||||
|
|
||||||
|
glat_vals = [abs(_safe(glat, i)) for i in idxs]
|
||||||
|
g_lat_max = max(glat_vals) if glat_vals else 0.0
|
||||||
|
|
||||||
|
full_throttle_m: float | None = None
|
||||||
|
apex_idx_in_idxs = spds.index(v_min)
|
||||||
|
post_apex = idxs[apex_idx_in_idxs:]
|
||||||
|
if laptime:
|
||||||
|
i = 0
|
||||||
|
while i < len(post_apex):
|
||||||
|
pi = post_apex[i]
|
||||||
|
if _safe(throttle, pi) >= FULL_THROTTLE:
|
||||||
|
t_start = _safe(laptime, pi)
|
||||||
|
j = i
|
||||||
|
while j < len(post_apex) and _safe(throttle, post_apex[j]) >= FULL_THROTTLE:
|
||||||
|
j += 1
|
||||||
|
t_end = _safe(laptime, post_apex[j - 1])
|
||||||
|
if (t_end - t_start) >= 0.3:
|
||||||
|
full_throttle_m = dist[pi]
|
||||||
|
break
|
||||||
|
i = j
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
for pi in post_apex:
|
||||||
|
if _safe(throttle, pi) >= FULL_THROTTLE:
|
||||||
|
full_throttle_m = dist[pi]
|
||||||
|
break
|
||||||
|
|
||||||
|
time_zone = 0.0
|
||||||
|
if laptime and idxs:
|
||||||
|
time_zone = _safe(laptime, ib) - _safe(laptime, ia)
|
||||||
|
|
||||||
|
# lock_pct: min relative front slip under braking (negative = locking tendency)
|
||||||
|
brake_slip_vals = []
|
||||||
|
for i in idxs:
|
||||||
|
if _safe(brake, i) > 20:
|
||||||
|
front = min(_safe(slip_fl, i), _safe(slip_fr, i))
|
||||||
|
spd = max(_safe(speed, i), _SAFE_SPEED_FLOOR)
|
||||||
|
brake_slip_vals.append(front / spd * 100.0)
|
||||||
|
lock_pct = min(brake_slip_vals) if brake_slip_vals else 0.0
|
||||||
|
|
||||||
|
# spin_pct: max relative rear slip on throttle (positive = spinning tendency)
|
||||||
|
throttle_slip_vals = []
|
||||||
|
for i in idxs:
|
||||||
|
if _safe(throttle, i) > 80:
|
||||||
|
rear = max(_safe(slip_rl, i), _safe(slip_rr, i))
|
||||||
|
spd = max(_safe(speed, i), _SAFE_SPEED_FLOOR)
|
||||||
|
throttle_slip_vals.append(rear / spd * 100.0)
|
||||||
|
spin_pct = max(throttle_slip_vals) if throttle_slip_vals else 0.0
|
||||||
|
|
||||||
|
lockup = lock_pct < LOCKUP_REL_PCT
|
||||||
|
wheelspin = spin_pct > SPIN_REL_PCT
|
||||||
|
|
||||||
|
brake_off_m: float | None = None
|
||||||
|
if brake_start_m is not None:
|
||||||
|
brake_start_idx = next((i for i in idxs if dist[i] >= brake_start_m), None)
|
||||||
|
if brake_start_idx is not None:
|
||||||
|
for i in idxs[idxs.index(brake_start_idx):idxs.index(apex_i) + 1]:
|
||||||
|
if _safe(brake, i) > BRAKE_MARK:
|
||||||
|
brake_off_m = dist[i]
|
||||||
|
|
||||||
|
coast_m = 0.0
|
||||||
|
if brake_off_m is not None and full_throttle_m is not None and full_throttle_m > brake_off_m:
|
||||||
|
coast_m = full_throttle_m - brake_off_m
|
||||||
|
|
||||||
|
rear_tyre_temp_peak = 0.0
|
||||||
|
if tyre_temp_rl and tyre_temp_rr:
|
||||||
|
peaks = [max(_safe(tyre_temp_rl, i), _safe(tyre_temp_rr, i)) for i in idxs]
|
||||||
|
rear_tyre_temp_peak = max(peaks) if peaks else 0.0
|
||||||
|
|
||||||
|
return Corner(
|
||||||
|
number=number,
|
||||||
|
start_m=dist[ia],
|
||||||
|
end_m=dist[ib],
|
||||||
|
brake_start_m=brake_start_m,
|
||||||
|
v_entry=v_entry,
|
||||||
|
v_min=v_min,
|
||||||
|
apex_m=apex_m,
|
||||||
|
gear_apex=gear_apex,
|
||||||
|
full_throttle_m=full_throttle_m,
|
||||||
|
v_exit=v_exit,
|
||||||
|
brake_max=brake_max,
|
||||||
|
g_lat_max=g_lat_max,
|
||||||
|
time_s=time_zone,
|
||||||
|
lock_pct=lock_pct,
|
||||||
|
spin_pct=spin_pct,
|
||||||
|
coast_m=coast_m,
|
||||||
|
rear_tyre_temp_peak=rear_tyre_temp_peak,
|
||||||
|
lockup=lockup,
|
||||||
|
wheelspin=wheelspin,
|
||||||
|
)
|
||||||
228
telemetry/gui.py
Normal file
228
telemetry/gui.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog, scrolledtext, ttk
|
||||||
|
|
||||||
|
from .parser import load_lap
|
||||||
|
from .report_common import report_path
|
||||||
|
from . import report_technique, report_setup, report_compare, report_race
|
||||||
|
from .rename import rename_unprocessed
|
||||||
|
|
||||||
|
# project root = parent of the tool\ dir
|
||||||
|
_PROJECT_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||||
|
_REPORTS_DIR = os.path.join(_PROJECT_ROOT, 'reports')
|
||||||
|
|
||||||
|
|
||||||
|
def _race_output_path(race_dir: str) -> str:
|
||||||
|
dirname = os.path.basename(race_dir.rstrip('/\\'))
|
||||||
|
return os.path.join(_PROJECT_ROOT, 'reports', dirname + '_race.md')
|
||||||
|
|
||||||
|
|
||||||
|
class _App:
|
||||||
|
def __init__(self, root: tk.Tk) -> None:
|
||||||
|
self._root = root
|
||||||
|
root.title('F1 Telemetry Analyzer')
|
||||||
|
root.resizable(True, True)
|
||||||
|
|
||||||
|
self._mode = tk.StringVar(value='technique')
|
||||||
|
self._lang = tk.StringVar(value='ru')
|
||||||
|
self._selection: list[str] = []
|
||||||
|
self._log_queue: queue.Queue[str | None] = queue.Queue()
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._poll_log()
|
||||||
|
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
pad = {'padx': 8, 'pady': 4}
|
||||||
|
|
||||||
|
mode_frame = ttk.LabelFrame(self._root, text='Mode')
|
||||||
|
mode_frame.pack(fill='x', **pad)
|
||||||
|
for label, value in [('Technique', 'technique'), ('Setup', 'setup'),
|
||||||
|
('Compare', 'compare'), ('Race', 'race')]:
|
||||||
|
ttk.Radiobutton(mode_frame, text=label, variable=self._mode,
|
||||||
|
value=value).pack(side='left', padx=6, pady=4)
|
||||||
|
|
||||||
|
lang_frame = ttk.LabelFrame(self._root, text='Prompt language / Язык промпта')
|
||||||
|
lang_frame.pack(fill='x', **pad)
|
||||||
|
ttk.Radiobutton(lang_frame, text='RU', variable=self._lang,
|
||||||
|
value='ru').pack(side='left', padx=6, pady=4)
|
||||||
|
ttk.Radiobutton(lang_frame, text='EN', variable=self._lang,
|
||||||
|
value='en').pack(side='left', padx=6, pady=4)
|
||||||
|
|
||||||
|
input_frame = ttk.LabelFrame(self._root, text='Input')
|
||||||
|
input_frame.pack(fill='x', **pad)
|
||||||
|
self._path_var = tk.StringVar()
|
||||||
|
ttk.Entry(input_frame, textvariable=self._path_var,
|
||||||
|
state='readonly', width=60).pack(side='left', fill='x',
|
||||||
|
expand=True, padx=(6, 2), pady=4)
|
||||||
|
ttk.Button(input_frame, text='Browse…',
|
||||||
|
command=self._browse).pack(side='left', padx=(2, 6), pady=4)
|
||||||
|
|
||||||
|
btn_frame = ttk.Frame(self._root)
|
||||||
|
btn_frame.pack(fill='x', **pad)
|
||||||
|
self._btn_generate = ttk.Button(btn_frame, text='Generate report',
|
||||||
|
command=self._on_generate)
|
||||||
|
self._btn_generate.pack(side='left', padx=(0, 4))
|
||||||
|
self._btn_rename = ttk.Button(btn_frame, text='Rename unprocessed (add lap #)',
|
||||||
|
command=self._on_rename)
|
||||||
|
self._btn_rename.pack(side='left', padx=4)
|
||||||
|
ttk.Button(btn_frame, text='Open reports folder',
|
||||||
|
command=self._open_reports).pack(side='left', padx=4)
|
||||||
|
|
||||||
|
log_frame = ttk.LabelFrame(self._root, text='Log')
|
||||||
|
log_frame.pack(fill='both', expand=True, **pad)
|
||||||
|
self._log = scrolledtext.ScrolledText(log_frame, state='disabled',
|
||||||
|
wrap='word', height=16)
|
||||||
|
self._log.pack(fill='both', expand=True, padx=4, pady=4)
|
||||||
|
|
||||||
|
def _browse(self) -> None:
|
||||||
|
mode = self._mode.get()
|
||||||
|
init = _PROJECT_ROOT
|
||||||
|
if mode == 'race':
|
||||||
|
d = filedialog.askdirectory(title='Select race folder', initialdir=init)
|
||||||
|
if d:
|
||||||
|
self._selection = [d]
|
||||||
|
self._path_var.set(d)
|
||||||
|
elif mode == 'compare':
|
||||||
|
files = filedialog.askopenfilenames(
|
||||||
|
title='Select 2+ CSV files', initialdir=init,
|
||||||
|
filetypes=[('CSV', '*.csv')])
|
||||||
|
if files:
|
||||||
|
self._selection = list(files)
|
||||||
|
self._path_var.set(' | '.join(os.path.basename(f) for f in files))
|
||||||
|
else:
|
||||||
|
f = filedialog.askopenfilename(
|
||||||
|
title='Select CSV file', initialdir=init,
|
||||||
|
filetypes=[('CSV', '*.csv')])
|
||||||
|
if f:
|
||||||
|
self._selection = [f]
|
||||||
|
self._path_var.set(f)
|
||||||
|
|
||||||
|
def _validate_selection(self) -> str | None:
|
||||||
|
mode = self._mode.get()
|
||||||
|
sel = self._selection
|
||||||
|
if not sel:
|
||||||
|
return 'No file selected'
|
||||||
|
if mode in ('technique', 'setup') and len(sel) != 1:
|
||||||
|
return f'{mode} requires exactly 1 file'
|
||||||
|
if mode == 'compare' and len(sel) < 2:
|
||||||
|
return 'compare requires 2+ files'
|
||||||
|
if mode == 'race' and (len(sel) != 1 or not os.path.isdir(sel[0])):
|
||||||
|
return 'race requires a directory'
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
self._busy = busy
|
||||||
|
state = 'disabled' if busy else 'normal'
|
||||||
|
self._btn_generate.configure(state=state)
|
||||||
|
self._btn_rename.configure(state=state)
|
||||||
|
|
||||||
|
def _log_append(self, text: str) -> None:
|
||||||
|
self._log.configure(state='normal')
|
||||||
|
self._log.insert('end', text + '\n')
|
||||||
|
self._log.see('end')
|
||||||
|
self._log.configure(state='disabled')
|
||||||
|
|
||||||
|
def _poll_log(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = self._log_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
if msg is None:
|
||||||
|
self._set_busy(False)
|
||||||
|
else:
|
||||||
|
self._log_append(msg)
|
||||||
|
self._root.after(100, self._poll_log)
|
||||||
|
|
||||||
|
def _push(self, msg: str) -> None:
|
||||||
|
self._log_queue.put(msg)
|
||||||
|
|
||||||
|
def _on_generate(self) -> None:
|
||||||
|
err = self._validate_selection()
|
||||||
|
if err:
|
||||||
|
self._log_append(f'✗ {err}')
|
||||||
|
return
|
||||||
|
mode = self._mode.get()
|
||||||
|
sel = list(self._selection)
|
||||||
|
lang = self._lang.get()
|
||||||
|
self._set_busy(True)
|
||||||
|
threading.Thread(target=self._worker_generate, args=(mode, sel, lang),
|
||||||
|
daemon=True).start()
|
||||||
|
|
||||||
|
def _worker_generate(self, mode: str, sel: list[str], lang: str) -> None:
|
||||||
|
try:
|
||||||
|
if mode == 'technique':
|
||||||
|
lap = load_lap(sel[0])
|
||||||
|
out = report_path(sel[0], 'technique')
|
||||||
|
abs_path, tokens = report_technique.generate(lap, out, lang)
|
||||||
|
self._push(f'✓ {abs_path} (~{tokens} tokens)')
|
||||||
|
|
||||||
|
elif mode == 'setup':
|
||||||
|
lap = load_lap(sel[0])
|
||||||
|
out = report_path(sel[0], 'setup')
|
||||||
|
abs_path, tokens = report_setup.generate(lap, out, lang)
|
||||||
|
self._push(f'✓ {abs_path} (~{tokens} tokens)')
|
||||||
|
|
||||||
|
elif mode == 'compare':
|
||||||
|
laps = [load_lap(f) for f in sel]
|
||||||
|
stem_b = os.path.splitext(os.path.basename(sel[1]))[0]
|
||||||
|
out = report_path(sel[0], 'compare', stem_b)
|
||||||
|
abs_path, tokens = report_compare.generate(laps, out, lang)
|
||||||
|
self._push(f'✓ {abs_path} (~{tokens} tokens)')
|
||||||
|
|
||||||
|
elif mode == 'race':
|
||||||
|
race_dir = os.path.abspath(sel[0])
|
||||||
|
csv_files = sorted(
|
||||||
|
f for f in os.listdir(race_dir) if f.lower().endswith('.csv')
|
||||||
|
)
|
||||||
|
if not csv_files:
|
||||||
|
self._push(f'✗ No CSV files found in {race_dir}')
|
||||||
|
return
|
||||||
|
laps = [load_lap(os.path.join(race_dir, f)) for f in csv_files]
|
||||||
|
out = _race_output_path(race_dir)
|
||||||
|
abs_path, tokens = report_race.generate(laps, out, lang)
|
||||||
|
self._push(f'✓ {abs_path} (~{tokens} tokens)')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._push(f'✗ {e}')
|
||||||
|
finally:
|
||||||
|
self._log_queue.put(None)
|
||||||
|
|
||||||
|
def _on_rename(self) -> None:
|
||||||
|
d = filedialog.askdirectory(title='Select folder to rename', initialdir=_PROJECT_ROOT)
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
self._set_busy(True)
|
||||||
|
threading.Thread(target=self._worker_rename, args=(d,), daemon=True).start()
|
||||||
|
|
||||||
|
def _worker_rename(self, d: str) -> None:
|
||||||
|
try:
|
||||||
|
results = rename_unprocessed([d])
|
||||||
|
if not results:
|
||||||
|
self._push('No CSV files found')
|
||||||
|
for r in results:
|
||||||
|
if r.status == 'renamed':
|
||||||
|
self._push(f'renamed: {r.old} -> {r.new}')
|
||||||
|
elif r.status == 'skipped':
|
||||||
|
self._push(f'skipped: {r.old} ({r.detail})')
|
||||||
|
else:
|
||||||
|
self._push(f'error: {r.old} ({r.detail})')
|
||||||
|
except Exception as e:
|
||||||
|
self._push(f'✗ {e}')
|
||||||
|
finally:
|
||||||
|
self._log_queue.put(None)
|
||||||
|
|
||||||
|
def _open_reports(self) -> None:
|
||||||
|
if not os.path.isdir(_REPORTS_DIR):
|
||||||
|
self._log_append(f'Reports folder does not exist yet: {_REPORTS_DIR}')
|
||||||
|
return
|
||||||
|
os.startfile(_REPORTS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
root = tk.Tk()
|
||||||
|
_App(root)
|
||||||
|
root.mainloop()
|
||||||
76
telemetry/parser.py
Normal file
76
telemetry/parser.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_unit(name: str) -> str:
|
||||||
|
return re.sub(r'\s*[\[({][^\]})]*[\]})]\s*$', '', name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_header_row(line: str) -> list[str]:
|
||||||
|
return [_strip_unit(h) for h in line.rstrip('\n').split(',')]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_value_row(line: str) -> list[str]:
|
||||||
|
return line.rstrip('\n').split(',')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Lap:
|
||||||
|
session: dict[str, str]
|
||||||
|
track: dict[str, str]
|
||||||
|
setup: dict[str, str]
|
||||||
|
ch: dict[str, list[float]]
|
||||||
|
n: int
|
||||||
|
|
||||||
|
|
||||||
|
def load_lap(path: str) -> Lap:
|
||||||
|
with open(path, encoding='utf-8') as f:
|
||||||
|
raw = f.readlines()
|
||||||
|
|
||||||
|
session_keys = _parse_header_row(raw[1])
|
||||||
|
session_vals = _parse_value_row(raw[2])
|
||||||
|
session = dict(zip(session_keys, session_vals))
|
||||||
|
|
||||||
|
track_keys = _parse_header_row(raw[3])
|
||||||
|
track_vals = _parse_value_row(raw[4])
|
||||||
|
track = dict(zip(track_keys, track_vals))
|
||||||
|
|
||||||
|
setup_keys = _parse_header_row(raw[5])
|
||||||
|
setup_vals = _parse_value_row(raw[6])
|
||||||
|
# drop trailing empty from the trailing comma
|
||||||
|
setup_vals = [v for v in setup_vals if v.strip() != '']
|
||||||
|
setup = dict(zip(setup_keys[:len(setup_vals)], setup_vals))
|
||||||
|
|
||||||
|
tel_keys = _parse_header_row(raw[7])
|
||||||
|
n_cols = len(tel_keys)
|
||||||
|
|
||||||
|
columns: dict[str, list[float]] = {k: [] for k in tel_keys}
|
||||||
|
for line in raw[8:]:
|
||||||
|
line = line.rstrip('\n')
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split(',')
|
||||||
|
if len(parts) < n_cols:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
vals = [float(parts[i]) for i in range(n_cols)]
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
for k, v in zip(tel_keys, vals):
|
||||||
|
columns[k].append(v)
|
||||||
|
|
||||||
|
# drop all-zero channels
|
||||||
|
ch = {k: v for k, v in columns.items() if any(x != 0.0 for x in v)}
|
||||||
|
|
||||||
|
return Lap(session=session, track=track, setup=setup, ch=ch, n=len(next(iter(columns.values()), [])))
|
||||||
|
|
||||||
|
|
||||||
|
def laptime_s(lap: Lap) -> float:
|
||||||
|
return float(lap.session.get('laptime', 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
def sector_times(lap: Lap) -> tuple[float, float, float]:
|
||||||
|
s1 = float(lap.session.get('S1', 0.0))
|
||||||
|
s2 = float(lap.session.get('S2', 0.0))
|
||||||
|
s3 = float(lap.session.get('S3', 0.0))
|
||||||
|
return s1, s2, s3
|
||||||
84
telemetry/rename.py
Normal file
84
telemetry/rename.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .parser import _parse_header_row, _parse_value_row
|
||||||
|
|
||||||
|
|
||||||
|
def lap_token_present(stem: str) -> bool:
|
||||||
|
return any(re.fullmatch(r'L\d+', t) for t in stem.split('_'))
|
||||||
|
|
||||||
|
|
||||||
|
def insert_lap_token(filename: str, lap: int) -> str:
|
||||||
|
base, ext = os.path.splitext(filename)
|
||||||
|
tokens = base.split('_')
|
||||||
|
token = f'L{lap}'
|
||||||
|
for i, t in enumerate(tokens):
|
||||||
|
if re.fullmatch(r'\d+(\.\d+)?', t):
|
||||||
|
tokens.insert(i, token)
|
||||||
|
return '_'.join(tokens) + ext
|
||||||
|
if len(tokens) >= 2:
|
||||||
|
tokens.insert(2, token)
|
||||||
|
else:
|
||||||
|
tokens.append(token)
|
||||||
|
return '_'.join(tokens) + ext
|
||||||
|
|
||||||
|
|
||||||
|
def read_lap_number(path: str) -> int:
|
||||||
|
with open(path, encoding='utf-8') as f:
|
||||||
|
lines = [f.readline() for _ in range(5)]
|
||||||
|
keys = _parse_header_row(lines[3])
|
||||||
|
vals = _parse_value_row(lines[4])
|
||||||
|
track = dict(zip(keys, vals))
|
||||||
|
return int(float(track['Lap']))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RenameResult:
|
||||||
|
old: str
|
||||||
|
new: str | None
|
||||||
|
status: str # 'renamed' | 'skipped' | 'error'
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def rename_unprocessed(targets: list[str]) -> list[RenameResult]:
|
||||||
|
paths: list[str] = []
|
||||||
|
for t in targets:
|
||||||
|
t = os.path.abspath(t)
|
||||||
|
if os.path.isdir(t):
|
||||||
|
for name in os.listdir(t):
|
||||||
|
if name.lower().endswith('.csv'):
|
||||||
|
paths.append(os.path.join(t, name))
|
||||||
|
else:
|
||||||
|
paths.append(t)
|
||||||
|
|
||||||
|
results: list[RenameResult] = []
|
||||||
|
for p in paths:
|
||||||
|
name = os.path.basename(p)
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
results.append(RenameResult(name, None, 'error', 'file not found'))
|
||||||
|
continue
|
||||||
|
if not name.lower().endswith('.csv'):
|
||||||
|
results.append(RenameResult(name, None, 'error', 'not a CSV file'))
|
||||||
|
continue
|
||||||
|
stem = os.path.splitext(name)[0]
|
||||||
|
if lap_token_present(stem):
|
||||||
|
results.append(RenameResult(name, None, 'skipped', 'already has lap number'))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lap = read_lap_number(p)
|
||||||
|
except Exception as e:
|
||||||
|
results.append(RenameResult(name, None, 'error', str(e)))
|
||||||
|
continue
|
||||||
|
new_name = insert_lap_token(name, lap)
|
||||||
|
dest = os.path.join(os.path.dirname(p), new_name)
|
||||||
|
if os.path.exists(dest):
|
||||||
|
results.append(RenameResult(name, new_name, 'error', 'target exists'))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.rename(p, dest)
|
||||||
|
except OSError as e:
|
||||||
|
results.append(RenameResult(name, new_name, 'error', str(e)))
|
||||||
|
continue
|
||||||
|
results.append(RenameResult(name, new_name, 'renamed', ''))
|
||||||
|
return results
|
||||||
124
telemetry/report_common.py
Normal file
124
telemetry/report_common.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from .parser import Lap, laptime_s, sector_times
|
||||||
|
|
||||||
|
|
||||||
|
def header_block(lap: Lap) -> str:
|
||||||
|
s = lap.session
|
||||||
|
t = lap.track
|
||||||
|
lt = laptime_s(lap)
|
||||||
|
s1, s2, s3 = sector_times(lap)
|
||||||
|
lap_n = t.get('Lap', '?')
|
||||||
|
laps_total = t.get('LapsInRace', '?')
|
||||||
|
fuel_start = t.get('Fuel at Start', '?')
|
||||||
|
try:
|
||||||
|
fuel_start = f'{float(fuel_start):.2f} kg'
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f'**Track:** {s.get("track", "?")} ',
|
||||||
|
f'**Car:** {s.get("car", "?")} ',
|
||||||
|
f'**Event:** {s.get("event", "?")} ',
|
||||||
|
f'**Laptime:** {lt:.3f} s (S1 {s1:.3f} / S2 {s2:.3f} / S3 {s3:.3f}) ',
|
||||||
|
f'**Tyre:** {t.get("Tyre", "?")} ',
|
||||||
|
f'**Weather:** {t.get("Weather", "?")} ',
|
||||||
|
f'**Track temp:** {t.get("TrackTemp", "?")} °C **Air:** {t.get("AmbientTemp", "?")} °C ',
|
||||||
|
f'**Lap:** {lap_n} / {laps_total} ',
|
||||||
|
f'**Fuel at start:** {fuel_start} ',
|
||||||
|
]
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_summary(lap: Lap) -> str:
|
||||||
|
s = lap.setup
|
||||||
|
return (
|
||||||
|
f'FWing {s.get("FWing","?")} / RWing {s.get("RWing","?")} | '
|
||||||
|
f'BrakeBias {s.get("BrakeBias","?")}% | '
|
||||||
|
f'FL {s.get("FLTyrePressure","?")} FR {s.get("FRTyrePressure","?")} '
|
||||||
|
f'RL {s.get("RLTyrePressure","?")} RR {s.get("RRTyrePressure","?")} PSI'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LEGEND_DIST = '- `dist` = LapDistance [m]'
|
||||||
|
LEGEND_TIME = '- `time` = elapsed LapTime [s]'
|
||||||
|
LEGEND_SPD = '- `spd` = Speed [km/h]'
|
||||||
|
LEGEND_THR_BRK = '- `thr` / `brk` = ThrottlePercentage / BrakePercentage [0–100 %]'
|
||||||
|
LEGEND_STEER = '- `steer` = steering lock [%], negative = left, positive = right'
|
||||||
|
LEGEND_GEAR = '- `gear` = selected gear (2–8)'
|
||||||
|
LEGEND_DRS = '- `drs` = DRS active (0 = off, 1 = on)'
|
||||||
|
LEGEND_GLAT = '- `gLat` = lateral G-force [G], positive = right corner'
|
||||||
|
LEGEND_GLON = '- `gLon` = longitudinal G-force [G], negative = braking, positive = acceleration'
|
||||||
|
LEGEND_FUEL = '- `fuel` = FuelRemaining [kg]'
|
||||||
|
LEGEND_ERS = '- `ers` = ERSSpent [MJ], cumulative energy deployed from ERS'
|
||||||
|
LEGEND_LOCK = '- LOCK flag = front relative slip (slip km/h / speed km/h × 100) < −10 % while braking (severe lockup only)'
|
||||||
|
LEGEND_SPIN = '- SPIN flag = rear relative slip > +8 % while on throttle (severe wheelspin only)'
|
||||||
|
LEGEND_TF = '- `Tf` / `Tr` = surface tyre temperature front/rear average [°C]'
|
||||||
|
LEGEND_RPM = '- `rpm` = engine revs (rounded to nearest 100)'
|
||||||
|
LEGEND_SLIP = '- `slipF` / `slipR` = relative wheel slip front/rear [%], negative front = locking tendency, positive rear = wheelspin tendency'
|
||||||
|
LEGEND_YAW = '- `yaw` = yaw rate [rad/s] (LocalAngularVelocityY); large steer + small yaw = understeer'
|
||||||
|
LEGEND_SUSP = '- Suspension positions in mm; rake = front avg − rear avg; roll = left avg − right avg per corner (positive = left side compressed)'
|
||||||
|
LEGEND_DT_SEG = '- `Δt seg` = per-segment time delta (positive = lap X slower in that zone)'
|
||||||
|
LEGEND_DT_CUM = '- `Δt cum` = cumulative delta at zone end vs lap A [s], negative = lap X faster up to that point'
|
||||||
|
LEGEND_STEER_G_CAVEAT = '- steer/g and slip balance are speed-dependent — only compare corners of similar Vmin'
|
||||||
|
|
||||||
|
|
||||||
|
def legend(entries: list[str]) -> str:
|
||||||
|
return '## Legend\n' + '\n'.join(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def md_table(headers: list[str], rows: list[list[str]]) -> str:
|
||||||
|
sep = ['---'] * len(headers)
|
||||||
|
lines = [
|
||||||
|
'| ' + ' | '.join(headers) + ' |',
|
||||||
|
'| ' + ' | '.join(sep) + ' |',
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
lines.append('| ' + ' | '.join(str(c) for c in row) + ' |')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def token_estimate(text: str) -> int:
|
||||||
|
return int(len(text) / 3.5)
|
||||||
|
|
||||||
|
|
||||||
|
def write_report(path: str, text: str) -> tuple[str, int]:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
dir_ = os.path.dirname(path)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=dir_, suffix='.tmp')
|
||||||
|
try:
|
||||||
|
# close the raw fd so we can open in text mode (platform newlines, UTF-8)
|
||||||
|
os.close(fd)
|
||||||
|
with open(tmp, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(text)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
tokens = token_estimate(text)
|
||||||
|
return os.path.abspath(path), tokens
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompt(mode: str, lang: str) -> str:
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
p = os.path.normpath(os.path.join(here, '..', 'prompts', lang, f'{mode}.md'))
|
||||||
|
try:
|
||||||
|
with open(p, encoding='utf-8') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def report_path(input_path: str, mode: str, other_stem: str = '') -> str:
|
||||||
|
d = os.path.dirname(os.path.abspath(input_path))
|
||||||
|
reports_dir = os.path.join(d, 'reports')
|
||||||
|
stem = os.path.splitext(os.path.basename(input_path))[0]
|
||||||
|
if mode == 'compare' and other_stem:
|
||||||
|
fname = f'compare_{stem}_vs_{other_stem}.md'
|
||||||
|
else:
|
||||||
|
fname = f'{stem}_{mode}.md'
|
||||||
|
return os.path.join(reports_dir, fname)
|
||||||
182
telemetry/report_compare.py
Normal file
182
telemetry/report_compare.py
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import bisect
|
||||||
|
|
||||||
|
from .parser import Lap, laptime_s, sector_times
|
||||||
|
from .segments import detect_corners, Corner
|
||||||
|
from .resample import adaptive_points, sample_at
|
||||||
|
from .report_common import (
|
||||||
|
legend, md_table, write_report, token_estimate, load_prompt,
|
||||||
|
LEGEND_DIST, LEGEND_SPD, LEGEND_DT_SEG, LEGEND_DT_CUM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _interp_laptime(lap: Lap, dist_query: float) -> float:
|
||||||
|
dist = lap.ch.get('LapDistance', [])
|
||||||
|
lt = lap.ch.get('LapTime', [])
|
||||||
|
if not dist or not lt:
|
||||||
|
return 0.0
|
||||||
|
if dist_query <= dist[0]:
|
||||||
|
return lt[0]
|
||||||
|
if dist_query >= dist[-1]:
|
||||||
|
return lt[-1]
|
||||||
|
i = bisect.bisect_left(dist, dist_query)
|
||||||
|
x0, x1 = dist[i - 1], dist[i]
|
||||||
|
y0, y1 = lt[i - 1], lt[i]
|
||||||
|
if x1 == x0:
|
||||||
|
return y0
|
||||||
|
return y0 + (y1 - y0) * (dist_query - x0) / (x1 - x0)
|
||||||
|
|
||||||
|
|
||||||
|
def _header_table(laps: list[Lap]) -> str:
|
||||||
|
headers = ['Metric'] + [f'Lap {i+1}' for i in range(len(laps))]
|
||||||
|
def _fuel_start(l: Lap) -> str:
|
||||||
|
raw = l.track.get('Fuel at Start', '?')
|
||||||
|
try:
|
||||||
|
return f'{float(raw):.2f}'
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return raw
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
('Laptime', lambda l: f'{laptime_s(l):.3f}'),
|
||||||
|
('S1', lambda l: f'{sector_times(l)[0]:.3f}'),
|
||||||
|
('S2', lambda l: f'{sector_times(l)[1]:.3f}'),
|
||||||
|
('S3', lambda l: f'{sector_times(l)[2]:.3f}'),
|
||||||
|
('Tyre', lambda l: l.track.get('Tyre', '?')),
|
||||||
|
('Fuel start kg', _fuel_start),
|
||||||
|
('Wear FL%', lambda l: _delta_wear(l, 'TyreWearFrontLeft')),
|
||||||
|
('Wear FR%', lambda l: _delta_wear(l, 'TyreWearFrontRight')),
|
||||||
|
('Wear RL%', lambda l: _delta_wear(l, 'TyreWearRearLeft')),
|
||||||
|
('Wear RR%', lambda l: _delta_wear(l, 'TyreWearRearRight')),
|
||||||
|
('Weather', lambda l: l.track.get('Weather', '?')),
|
||||||
|
]
|
||||||
|
rows = []
|
||||||
|
for label, fn in fields:
|
||||||
|
rows.append([label] + [fn(lap) for lap in laps])
|
||||||
|
return md_table(headers, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_wear(lap: Lap, key: str) -> str:
|
||||||
|
v = lap.ch.get(key, [])
|
||||||
|
if len(v) >= 2:
|
||||||
|
return f'{v[-1]-v[0]:.2f}'
|
||||||
|
return '?'
|
||||||
|
|
||||||
|
|
||||||
|
def _corner_table(laps: list[Lap], corners: list[Corner]) -> str:
|
||||||
|
n = len(laps)
|
||||||
|
# build column labels for each non-reference lap
|
||||||
|
seg_headers = [f'Δt seg L{k+1}−L1' for k in range(1, n)]
|
||||||
|
cum_headers = [f'Δt cum L{k+1}−L1' for k in range(1, n)]
|
||||||
|
headers = ['T'] + [f'Vmin L{i+1}' for i in range(n)] + \
|
||||||
|
[f'time L{i+1}' for i in range(n)] + \
|
||||||
|
seg_headers + cum_headers
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for c in corners:
|
||||||
|
row = [str(c.number)]
|
||||||
|
vmins = []
|
||||||
|
seg_times: list[float | None] = []
|
||||||
|
for lap in laps:
|
||||||
|
d = lap.ch.get('LapDistance', [])
|
||||||
|
spd = lap.ch.get('Speed', [])
|
||||||
|
idxs = [i for i, v in enumerate(d) if c.start_m <= v <= c.end_m]
|
||||||
|
if idxs and spd:
|
||||||
|
spds = [spd[i] for i in idxs if i < len(spd)]
|
||||||
|
vmins.append(f'{min(spds):.0f}')
|
||||||
|
else:
|
||||||
|
vmins.append('—')
|
||||||
|
if lap.ch.get('LapDistance') and lap.ch.get('LapTime'):
|
||||||
|
t = _interp_laptime(lap, c.end_m) - _interp_laptime(lap, c.start_m)
|
||||||
|
seg_times.append(t)
|
||||||
|
else:
|
||||||
|
seg_times.append(None)
|
||||||
|
|
||||||
|
times = [f'{t:.2f}' if t is not None else '—' for t in seg_times]
|
||||||
|
row += vmins + times
|
||||||
|
|
||||||
|
lap_a = laps[0]
|
||||||
|
seg_a = seg_times[0]
|
||||||
|
|
||||||
|
# per-segment Δt
|
||||||
|
for k in range(1, n):
|
||||||
|
if seg_times[k] is not None and seg_a is not None:
|
||||||
|
row.append(f'{seg_times[k] - seg_a:+.3f}')
|
||||||
|
else:
|
||||||
|
row.append('—')
|
||||||
|
|
||||||
|
# cumulative Δt at corner end
|
||||||
|
ta_end = _interp_laptime(lap_a, c.end_m)
|
||||||
|
for k in range(1, n):
|
||||||
|
lap_k = laps[k]
|
||||||
|
if lap_k.ch.get('LapDistance') and lap_k.ch.get('LapTime'):
|
||||||
|
tk_end = _interp_laptime(lap_k, c.end_m)
|
||||||
|
row.append(f'{tk_end - ta_end:+.3f}')
|
||||||
|
else:
|
||||||
|
row.append('—')
|
||||||
|
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
return md_table(headers, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_trace(laps: list[Lap], corners: list[Corner]) -> tuple[str, list[float]]:
|
||||||
|
lap_a = laps[0]
|
||||||
|
dists = adaptive_points(lap_a, corners)
|
||||||
|
if not dists:
|
||||||
|
return '', []
|
||||||
|
|
||||||
|
spds = [sample_at(lap, 'Speed', dists) for lap in laps]
|
||||||
|
|
||||||
|
header_parts = ['dist'] + [f'spd_L{i+1}' for i in range(len(laps))] + \
|
||||||
|
[f'Δt_L{i+1}' for i in range(1, len(laps))]
|
||||||
|
header = ','.join(header_parts)
|
||||||
|
|
||||||
|
final_deltas: list[float] = []
|
||||||
|
rows_str: list[str] = []
|
||||||
|
for j, d in enumerate(dists):
|
||||||
|
parts = [f'{d:.0f}']
|
||||||
|
for i, lap in enumerate(laps):
|
||||||
|
parts.append(f'{spds[i][j]:.0f}')
|
||||||
|
deltas = []
|
||||||
|
for k in range(1, len(laps)):
|
||||||
|
ta = _interp_laptime(lap_a, d)
|
||||||
|
tk = _interp_laptime(laps[k], d)
|
||||||
|
deltas.append(f'{tk - ta:+.3f}')
|
||||||
|
parts += deltas
|
||||||
|
rows_str.append(','.join(parts))
|
||||||
|
if j == len(dists) - 1:
|
||||||
|
for k in range(1, len(laps)):
|
||||||
|
ta = _interp_laptime(lap_a, d)
|
||||||
|
tk = _interp_laptime(laps[k], d)
|
||||||
|
final_deltas.append(tk - ta)
|
||||||
|
|
||||||
|
block = '```\n' + header + '\n' + '\n'.join(rows_str) + '\n```'
|
||||||
|
return block, final_deltas
|
||||||
|
|
||||||
|
|
||||||
|
def generate(laps: list[Lap], out_path: str, lang: str = 'ru') -> tuple[str, int]:
|
||||||
|
lap_a = laps[0]
|
||||||
|
corners = detect_corners(lap_a)
|
||||||
|
|
||||||
|
head_table = _header_table(laps)
|
||||||
|
corner_table = _corner_table(laps, corners)
|
||||||
|
delta_block, final_deltas = _delta_trace(laps, corners)
|
||||||
|
|
||||||
|
for k in range(1, len(laps)):
|
||||||
|
lt_diff = laptime_s(laps[k]) - laptime_s(lap_a)
|
||||||
|
final_dt = final_deltas[k - 1] if (k - 1) < len(final_deltas) else float('nan')
|
||||||
|
print(f'Sanity L{k+1} vs L1: laptime diff={lt_diff:+.3f}s final cumulative dt={final_dt:+.3f}s')
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
'# Lap Comparison\n',
|
||||||
|
head_table,
|
||||||
|
'\n## Corners',
|
||||||
|
corner_table,
|
||||||
|
'\n## Delta trace',
|
||||||
|
delta_block,
|
||||||
|
'\n' + legend([LEGEND_DIST, LEGEND_SPD, LEGEND_DT_SEG, LEGEND_DT_CUM]),
|
||||||
|
]
|
||||||
|
text = '\n'.join(parts)
|
||||||
|
prompt = load_prompt('compare', lang)
|
||||||
|
if prompt:
|
||||||
|
text += '\n\n---\n\n' + prompt
|
||||||
|
return write_report(out_path, text)
|
||||||
277
telemetry/report_race.py
Normal file
277
telemetry/report_race.py
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
from .parser import Lap, laptime_s, sector_times
|
||||||
|
from .report_common import md_table, write_report, load_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_laptime(s: float) -> str:
|
||||||
|
mins = int(s) // 60
|
||||||
|
secs = s - mins * 60
|
||||||
|
return f'{mins}:{secs:06.3f}'
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_sector(s: float) -> str:
|
||||||
|
return f'{s:.3f}'
|
||||||
|
|
||||||
|
|
||||||
|
def _fuel_start(lap: Lap) -> float:
|
||||||
|
raw = lap.track.get('Fuel at Start', lap.track.get('FuelAtStart', '0'))
|
||||||
|
raw = raw.strip().split()[0] if raw.strip() else '0'
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
v = lap.ch.get('FuelRemaining', [0.0])
|
||||||
|
return v[0] if v else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_laps(laps: list[Lap]) -> list[Lap]:
|
||||||
|
def lap_num(lap: Lap) -> int:
|
||||||
|
try:
|
||||||
|
return int(lap.track.get('Lap', '0'))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
return sorted(laps, key=lap_num)
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_stints(laps: list[Lap]) -> tuple[list[int], list[int]]:
|
||||||
|
stints: list[int] = []
|
||||||
|
ages: list[int] = []
|
||||||
|
stint = 1
|
||||||
|
age = 1
|
||||||
|
prev_tyre = ''
|
||||||
|
prev_was_pit = False
|
||||||
|
|
||||||
|
for i, lap in enumerate(laps):
|
||||||
|
tyre = lap.track.get('Tyre', '')
|
||||||
|
if i > 0 and (prev_was_pit or tyre != prev_tyre):
|
||||||
|
stint += 1
|
||||||
|
age = 1
|
||||||
|
|
||||||
|
stints.append(stint)
|
||||||
|
ages.append(age)
|
||||||
|
|
||||||
|
prev_tyre = tyre
|
||||||
|
prev_was_pit = lap.track.get('Pitlap', 'false').lower() == 'true'
|
||||||
|
age += 1
|
||||||
|
|
||||||
|
return stints, ages
|
||||||
|
|
||||||
|
|
||||||
|
def _header(laps: list[Lap]) -> str:
|
||||||
|
if not laps:
|
||||||
|
return ''
|
||||||
|
first = laps[0]
|
||||||
|
s = first.session
|
||||||
|
t = first.track
|
||||||
|
track = s.get('track', '?')
|
||||||
|
car = s.get('car', '?')
|
||||||
|
total = len(laps)
|
||||||
|
weather = t.get('Weather', '?')
|
||||||
|
track_temp = t.get('TrackTemp', '?')
|
||||||
|
air_temp = t.get('AmbientTemp', '?')
|
||||||
|
lines = [
|
||||||
|
f'**Track:** {track} ',
|
||||||
|
f'**Car:** {car} ',
|
||||||
|
f'**Total laps:** {total} ',
|
||||||
|
f'**Weather:** {weather} ',
|
||||||
|
f'**Track temp:** {track_temp} °C **Air:** {air_temp} °C ',
|
||||||
|
]
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _lap_times_table(laps: list[Lap], stints: list[int], ages: list[int]) -> str:
|
||||||
|
# find fastest valid lap time
|
||||||
|
best_time = None
|
||||||
|
for lap in laps:
|
||||||
|
valid = lap.track.get('Valid', 'true').lower() != 'false'
|
||||||
|
pit = lap.track.get('Pitlap', 'false').lower() == 'true'
|
||||||
|
if valid and not pit:
|
||||||
|
lt = laptime_s(lap)
|
||||||
|
if lt > 0 and (best_time is None or lt < best_time):
|
||||||
|
best_time = lt
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for i, lap in enumerate(laps):
|
||||||
|
lap_n = lap.track.get('Lap', str(i + 1))
|
||||||
|
lt = laptime_s(lap)
|
||||||
|
s1, s2, s3 = sector_times(lap)
|
||||||
|
tyre = lap.track.get('Tyre', '?')
|
||||||
|
age = ages[i]
|
||||||
|
fuel = _fuel_start(lap)
|
||||||
|
valid = lap.track.get('Valid', 'true').lower() != 'false'
|
||||||
|
pit = lap.track.get('Pitlap', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
if pit:
|
||||||
|
notes.append('PIT')
|
||||||
|
if not valid:
|
||||||
|
notes.append('INV')
|
||||||
|
if valid and not pit and best_time is not None and lt == best_time:
|
||||||
|
notes.append('FAST')
|
||||||
|
|
||||||
|
rows.append([
|
||||||
|
lap_n,
|
||||||
|
_fmt_laptime(lt) if lt > 0 else '—',
|
||||||
|
_fmt_sector(s1) if s1 > 0 else '—',
|
||||||
|
_fmt_sector(s2) if s2 > 0 else '—',
|
||||||
|
_fmt_sector(s3) if s3 > 0 else '—',
|
||||||
|
tyre,
|
||||||
|
str(age),
|
||||||
|
f'{fuel:.1f}',
|
||||||
|
' '.join(notes),
|
||||||
|
])
|
||||||
|
|
||||||
|
return md_table(
|
||||||
|
['Lap', 'Time', 'S1', 'S2', 'S3', 'Tyre', 'Age', 'Fuel kg', 'Note'],
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stint_summary(laps: list[Lap], stints: list[int], ages: list[int]) -> str:
|
||||||
|
# group by stint
|
||||||
|
stint_map: dict[int, list[tuple[Lap, int]]] = {}
|
||||||
|
for i, lap in enumerate(laps):
|
||||||
|
st = stints[i]
|
||||||
|
stint_map.setdefault(st, []).append((lap, ages[i]))
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for st in sorted(stint_map):
|
||||||
|
entries = stint_map[st]
|
||||||
|
tyre = entries[0][0].track.get('Tyre', '?')
|
||||||
|
laps_on = max(age for _, age in entries)
|
||||||
|
n_laps = len(entries)
|
||||||
|
|
||||||
|
# exclude pit laps from stats
|
||||||
|
valid_entries = [
|
||||||
|
(lap, age) for lap, age in entries
|
||||||
|
if lap.track.get('Valid', 'true').lower() != 'false'
|
||||||
|
and lap.track.get('Pitlap', 'false').lower() != 'true'
|
||||||
|
]
|
||||||
|
times = [laptime_s(lap) for lap, _ in valid_entries if laptime_s(lap) > 0]
|
||||||
|
|
||||||
|
if times:
|
||||||
|
avg = sum(times) / len(times)
|
||||||
|
best = min(times)
|
||||||
|
worst = max(times)
|
||||||
|
avg_s = _fmt_laptime(avg)
|
||||||
|
best_s = _fmt_laptime(best)
|
||||||
|
worst_s = _fmt_laptime(worst)
|
||||||
|
else:
|
||||||
|
avg_s = best_s = worst_s = '—'
|
||||||
|
|
||||||
|
# degradation: slope of lap time vs tyre age
|
||||||
|
if len(valid_entries) >= 2:
|
||||||
|
first_lap, first_age = valid_entries[0]
|
||||||
|
last_lap, last_age = valid_entries[-1]
|
||||||
|
ft = laptime_s(first_lap)
|
||||||
|
lt = laptime_s(last_lap)
|
||||||
|
da = last_age - first_age
|
||||||
|
if da > 0 and ft > 0 and lt > 0:
|
||||||
|
deg = (lt - ft) / da
|
||||||
|
deg_s = f'{deg:+.3f}'
|
||||||
|
else:
|
||||||
|
deg_s = '—'
|
||||||
|
else:
|
||||||
|
deg_s = '—'
|
||||||
|
|
||||||
|
rows.append([
|
||||||
|
str(st),
|
||||||
|
str(n_laps),
|
||||||
|
tyre,
|
||||||
|
str(laps_on),
|
||||||
|
avg_s,
|
||||||
|
best_s,
|
||||||
|
worst_s,
|
||||||
|
deg_s,
|
||||||
|
])
|
||||||
|
|
||||||
|
return md_table(
|
||||||
|
['Stint', 'Laps', 'Tyre', 'Laps on tyre', 'Avg pace', 'Best', 'Worst', 'Deg s/lap'],
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tyre_wear_table(laps: list[Lap]) -> str:
|
||||||
|
rows = []
|
||||||
|
for i, lap in enumerate(laps):
|
||||||
|
lap_n = lap.track.get('Lap', str(i + 1))
|
||||||
|
ch = lap.ch
|
||||||
|
fl = ch.get('TyreWearFrontLeft', [])
|
||||||
|
fr = ch.get('TyreWearFrontRight', [])
|
||||||
|
rl = ch.get('TyreWearRearLeft', [])
|
||||||
|
rr = ch.get('TyreWearRearRight', [])
|
||||||
|
rows.append([
|
||||||
|
lap_n,
|
||||||
|
f'{fl[-1]:.2f}' if fl else '—',
|
||||||
|
f'{fr[-1]:.2f}' if fr else '—',
|
||||||
|
f'{rl[-1]:.2f}' if rl else '—',
|
||||||
|
f'{rr[-1]:.2f}' if rr else '—',
|
||||||
|
])
|
||||||
|
return md_table(['Lap', 'FL%', 'FR%', 'RL%', 'RR%'], rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _thermal_table(laps: list[Lap]) -> str:
|
||||||
|
rows = []
|
||||||
|
for i, lap in enumerate(laps):
|
||||||
|
lap_n = lap.track.get('Lap', str(i + 1))
|
||||||
|
ch = lap.ch
|
||||||
|
|
||||||
|
tf_fl = ch.get('TyreTemperatureFrontLeft', [])
|
||||||
|
tf_fr = ch.get('TyreTemperatureFrontRight', [])
|
||||||
|
tr_rl = ch.get('TyreTemperatureRearLeft', [])
|
||||||
|
tr_rr = ch.get('TyreTemperatureRearRight', [])
|
||||||
|
|
||||||
|
tf_avg = ((max(tf_fl) if tf_fl else 0) + (max(tf_fr) if tf_fr else 0)) / 2
|
||||||
|
tr_avg = ((max(tr_rl) if tr_rl else 0) + (max(tr_rr) if tr_rr else 0)) / 2
|
||||||
|
|
||||||
|
bt_fl = ch.get('BrakeTemperatureFrontLeft', [])
|
||||||
|
bt_rr = ch.get('BrakeTemperatureRearRight', [])
|
||||||
|
|
||||||
|
rows.append([
|
||||||
|
lap_n,
|
||||||
|
f'{tf_avg:.0f}' if tf_fl or tf_fr else '—',
|
||||||
|
f'{tr_avg:.0f}' if tr_rl or tr_rr else '—',
|
||||||
|
f'{max(bt_fl):.0f}' if bt_fl else '—',
|
||||||
|
f'{max(bt_rr):.0f}' if bt_rr else '—',
|
||||||
|
])
|
||||||
|
return md_table(['Lap', 'Tf avg°C', 'Tr avg°C', 'Brk FL max°C', 'Brk RR max°C'], rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _ers_table(laps: list[Lap]) -> str:
|
||||||
|
rows = []
|
||||||
|
for i, lap in enumerate(laps):
|
||||||
|
lap_n = lap.track.get('Lap', str(i + 1))
|
||||||
|
ch = lap.ch
|
||||||
|
ers = ch.get('ERSSpent', [])
|
||||||
|
mguk = ch.get('MGUKHarvested', [])
|
||||||
|
mguh = ch.get('MGUHHarvested', [])
|
||||||
|
rows.append([
|
||||||
|
lap_n,
|
||||||
|
f'{ers[-1] / 1e6:.3f}' if ers else '—',
|
||||||
|
f'{mguk[-1] / 1e6:.3f}' if mguk else '—',
|
||||||
|
f'{mguh[-1] / 1e6:.3f}' if mguh else '—',
|
||||||
|
])
|
||||||
|
return md_table(['Lap', 'ERS MJ', 'MGU-K MJ', 'MGU-H MJ'], rows)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(laps: list[Lap], out_path: str, lang: str = 'ru') -> tuple[str, int]:
|
||||||
|
laps = _sort_laps(laps)
|
||||||
|
stints, ages = _assign_stints(laps)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
_header(laps),
|
||||||
|
'\n## Lap times',
|
||||||
|
_lap_times_table(laps, stints, ages),
|
||||||
|
'\n## Stint summary',
|
||||||
|
_stint_summary(laps, stints, ages),
|
||||||
|
'\n## Tyre wear per lap',
|
||||||
|
_tyre_wear_table(laps),
|
||||||
|
'\n## Thermal summary per lap',
|
||||||
|
_thermal_table(laps),
|
||||||
|
'\n## ERS per lap',
|
||||||
|
_ers_table(laps),
|
||||||
|
]
|
||||||
|
text = '\n'.join(parts)
|
||||||
|
prompt = load_prompt('race', lang)
|
||||||
|
if prompt:
|
||||||
|
text += '\n\n---\n\n' + prompt
|
||||||
|
return write_report(out_path, text)
|
||||||
255
telemetry/report_setup.py
Normal file
255
telemetry/report_setup.py
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
from .parser import Lap
|
||||||
|
from .segments import detect_corners, Corner
|
||||||
|
from .segments import _indices_in_range
|
||||||
|
from .report_common import (
|
||||||
|
header_block, legend, md_table, write_report, load_prompt,
|
||||||
|
LEGEND_DIST, LEGEND_TIME, LEGEND_SPD, LEGEND_THR_BRK, LEGEND_STEER,
|
||||||
|
LEGEND_GEAR, LEGEND_DRS, LEGEND_GLAT, LEGEND_GLON, LEGEND_FUEL,
|
||||||
|
LEGEND_LOCK, LEGEND_SPIN, LEGEND_STEER_G_CAVEAT, LEGEND_SUSP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_avg(vals: list[float]) -> float:
|
||||||
|
return sum(vals) / len(vals) if vals else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_min(vals: list[float]) -> float:
|
||||||
|
return min(vals) if vals else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_max(vals: list[float]) -> float:
|
||||||
|
return max(vals) if vals else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _median(vals: list[float]) -> float:
|
||||||
|
if not vals:
|
||||||
|
return 0.0
|
||||||
|
s = sorted(vals)
|
||||||
|
n = len(s)
|
||||||
|
mid = n // 2
|
||||||
|
return s[mid] if n % 2 else (s[mid - 1] + s[mid]) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _corner_balance(lap: Lap, corners: list[Corner]) -> str:
|
||||||
|
dist = lap.ch.get('LapDistance', [])
|
||||||
|
glat = lap.ch.get('GForceLatitudinal', [])
|
||||||
|
steer = lap.ch.get('Steer', [])
|
||||||
|
slip_fl = lap.ch.get('WheelSlipFrontLeft', [])
|
||||||
|
slip_fr = lap.ch.get('WheelSlipFrontRight', [])
|
||||||
|
slip_rl = lap.ch.get('WheelSlipRearLeft', [])
|
||||||
|
slip_rr = lap.ch.get('WheelSlipRearRight', [])
|
||||||
|
|
||||||
|
# lap-wide axle baselines
|
||||||
|
all_front = [abs(v) for v in slip_fl] + [abs(v) for v in slip_fr]
|
||||||
|
all_rear = [abs(v) for v in slip_rl] + [abs(v) for v in slip_rr]
|
||||||
|
front_baseline = _median(all_front)
|
||||||
|
rear_baseline = _median(all_rear)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for c in corners:
|
||||||
|
idxs = _indices_in_range(dist, c.start_m, c.end_m)
|
||||||
|
if not idxs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
glat_vals = [abs(glat[i]) for i in idxs if i < len(glat)]
|
||||||
|
steer_vals = [abs(steer[i]) for i in idxs if i < len(steer)]
|
||||||
|
glat_avg = _safe_avg(glat_vals)
|
||||||
|
steer_per_g = (_safe_avg(steer_vals) / glat_avg) if glat_avg > 0 else 0.0
|
||||||
|
|
||||||
|
front_slip = [abs(slip_fl[i]) for i in idxs if i < len(slip_fl)] + \
|
||||||
|
[abs(slip_fr[i]) for i in idxs if i < len(slip_fr)]
|
||||||
|
rear_slip = [abs(slip_rl[i]) for i in idxs if i < len(slip_rl)] + \
|
||||||
|
[abs(slip_rr[i]) for i in idxs if i < len(slip_rr)]
|
||||||
|
front_avg = _safe_avg(front_slip)
|
||||||
|
rear_avg = _safe_avg(rear_slip)
|
||||||
|
|
||||||
|
front_excess = front_avg - front_baseline
|
||||||
|
rear_excess = rear_avg - rear_baseline
|
||||||
|
diff = front_excess - rear_excess
|
||||||
|
if diff > 0.5:
|
||||||
|
balance = 'US'
|
||||||
|
elif diff < -0.5:
|
||||||
|
balance = 'OS'
|
||||||
|
else:
|
||||||
|
balance = '—'
|
||||||
|
|
||||||
|
rows.append([
|
||||||
|
str(c.number),
|
||||||
|
f'{c.v_min:.0f}',
|
||||||
|
f'{c.g_lat_max:.2f}',
|
||||||
|
f'{steer_per_g:.2f}',
|
||||||
|
f'{front_avg:.3f}',
|
||||||
|
f'{rear_avg:.3f}',
|
||||||
|
balance,
|
||||||
|
])
|
||||||
|
|
||||||
|
return md_table(
|
||||||
|
['T', 'Vmin', 'gLat_max', 'steer/g', 'front|slip|', 'rear|slip|', 'balance'],
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tyre_section(lap: Lap) -> str:
|
||||||
|
ch = lap.ch
|
||||||
|
setup = lap.setup
|
||||||
|
wheels = ['FrontLeft', 'FrontRight', 'RearLeft', 'RearRight']
|
||||||
|
rows = []
|
||||||
|
for w in wheels:
|
||||||
|
wear = ch.get(f'TyreWear{w}', [])
|
||||||
|
wear_d = f'{wear[-1]-wear[0]:.2f}' if len(wear) >= 2 else '—'
|
||||||
|
|
||||||
|
surf = ch.get(f'TyreTemperature{w}', [])
|
||||||
|
if surf:
|
||||||
|
surf_min = f'{_safe_min(surf):.0f}'
|
||||||
|
surf_avg = f'{_safe_avg(surf):.0f}'
|
||||||
|
surf_max = f'{_safe_max(surf):.0f}'
|
||||||
|
else:
|
||||||
|
surf_min = surf_avg = surf_max = '—'
|
||||||
|
|
||||||
|
carc = ch.get(f'TyreCarcassTemperature{w}', [])
|
||||||
|
if carc:
|
||||||
|
carc_min = f'{_safe_min(carc):.0f}'
|
||||||
|
carc_avg = f'{_safe_avg(carc):.0f}'
|
||||||
|
carc_max = f'{_safe_max(carc):.0f}'
|
||||||
|
else:
|
||||||
|
carc_min = carc_avg = carc_max = '—'
|
||||||
|
|
||||||
|
press_ch = ch.get(f'TyrePressure{w}', [])
|
||||||
|
if press_ch and any(v != 0.0 for v in press_ch):
|
||||||
|
press = f'{_safe_avg(press_ch):.1f} PSI'
|
||||||
|
else:
|
||||||
|
key_map = {'FrontLeft': 'FLTyrePressure', 'FrontRight': 'FRTyrePressure',
|
||||||
|
'RearLeft': 'RLTyrePressure', 'RearRight': 'RRTyrePressure'}
|
||||||
|
press = setup.get(key_map.get(w, ''), '—') + ' PSI'
|
||||||
|
|
||||||
|
rows.append([w, wear_d, f'{surf_min}/{surf_avg}/{surf_max}',
|
||||||
|
f'{carc_min}/{carc_avg}/{carc_max}', press])
|
||||||
|
|
||||||
|
return md_table(['Wheel', 'wearΔ%', 'surf min/avg/max °C', 'carc min/avg/max °C', 'pressure'], rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _brake_section(lap: Lap) -> str:
|
||||||
|
ch = lap.ch
|
||||||
|
wheels = ['FrontLeft', 'FrontRight', 'RearLeft', 'RearRight']
|
||||||
|
rows = []
|
||||||
|
for w in wheels:
|
||||||
|
bt = ch.get(f'BrakeTemperature{w}', [])
|
||||||
|
if bt:
|
||||||
|
rows.append([w, f'{_safe_min(bt):.0f}', f'{_safe_avg(bt):.0f}', f'{_safe_max(bt):.0f}'])
|
||||||
|
else:
|
||||||
|
rows.append([w, '—', '—', '—'])
|
||||||
|
return md_table(['Wheel', 'min °C', 'avg °C', 'max °C'], rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _lap_phases(lap: Lap) -> str:
|
||||||
|
dist = lap.ch.get('LapDistance', [])
|
||||||
|
if not dist:
|
||||||
|
return ''
|
||||||
|
d_min, d_max = dist[0], dist[-1]
|
||||||
|
third = (d_max - d_min) / 3
|
||||||
|
phases = [
|
||||||
|
('Phase 1', d_min, d_min + third),
|
||||||
|
('Phase 2', d_min + third, d_min + 2 * third),
|
||||||
|
('Phase 3', d_min + 2 * third, d_max),
|
||||||
|
]
|
||||||
|
wheels = ['FrontLeft', 'FrontRight', 'RearLeft', 'RearRight']
|
||||||
|
headers = ['Phase'] + wheels
|
||||||
|
rows = []
|
||||||
|
for label, start, end in phases:
|
||||||
|
idxs = _indices_in_range(dist, start, end)
|
||||||
|
row = [label]
|
||||||
|
for w in wheels:
|
||||||
|
surf = lap.ch.get(f'TyreTemperature{w}', [])
|
||||||
|
vals = [surf[i] for i in idxs if i < len(surf)]
|
||||||
|
row.append(f'{_safe_avg(vals):.0f}' if vals else '—')
|
||||||
|
rows.append(row)
|
||||||
|
return md_table(headers, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_setup_table(lap: Lap) -> str:
|
||||||
|
rows = [[k, v] for k, v in lap.setup.items()]
|
||||||
|
return md_table(['Parameter', 'Value'], rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _suspension_section(lap: Lap, corners: list[Corner]) -> str | None:
|
||||||
|
ch = lap.ch
|
||||||
|
susp_keys = ['SuspensionPositionFrontLeft', 'SuspensionPositionFrontRight',
|
||||||
|
'SuspensionPositionRearLeft', 'SuspensionPositionRearRight']
|
||||||
|
if not all(k in ch for k in susp_keys):
|
||||||
|
return None
|
||||||
|
|
||||||
|
fl = ch['SuspensionPositionFrontLeft']
|
||||||
|
fr = ch['SuspensionPositionFrontRight']
|
||||||
|
rl = ch['SuspensionPositionRearLeft']
|
||||||
|
rr = ch['SuspensionPositionRearRight']
|
||||||
|
|
||||||
|
front_avg_mm = _safe_avg([v * 1000 for v in fl] + [v * 1000 for v in fr])
|
||||||
|
rear_avg_mm = _safe_avg([v * 1000 for v in rl] + [v * 1000 for v in rr])
|
||||||
|
rake_mm = front_avg_mm - rear_avg_mm
|
||||||
|
|
||||||
|
rake_line = (
|
||||||
|
f'Rake: front avg {front_avg_mm:.1f} mm / rear avg {rear_avg_mm:.1f} mm'
|
||||||
|
f' / Δ (rake) {rake_mm:.1f} mm'
|
||||||
|
)
|
||||||
|
|
||||||
|
dist = ch.get('LapDistance', [])
|
||||||
|
glat = ch.get('GForceLatitudinal', [])
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for c in corners:
|
||||||
|
idxs = _indices_in_range(dist, c.start_m, c.end_m)
|
||||||
|
if not idxs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
fl_z = [fl[i] * 1000 for i in idxs if i < len(fl)]
|
||||||
|
fr_z = [fr[i] * 1000 for i in idxs if i < len(fr)]
|
||||||
|
rl_z = [rl[i] * 1000 for i in idxs if i < len(rl)]
|
||||||
|
rr_z = [rr[i] * 1000 for i in idxs if i < len(rr)]
|
||||||
|
|
||||||
|
left_avg = _safe_avg(fl_z + rl_z)
|
||||||
|
right_avg = _safe_avg(fr_z + rr_z)
|
||||||
|
roll_mm = left_avg - right_avg
|
||||||
|
|
||||||
|
glat_vals = [glat[i] for i in idxs if i < len(glat)]
|
||||||
|
mean_glat = _safe_avg(glat_vals)
|
||||||
|
glat_sign = '+' if mean_glat >= 0 else '-'
|
||||||
|
|
||||||
|
rows.append([str(c.number), f'{c.v_min:.0f}', f'{roll_mm:.1f}', glat_sign])
|
||||||
|
|
||||||
|
roll_table = md_table(['T', 'Vmin', 'roll mm', 'gLat sign'], rows)
|
||||||
|
return rake_line + '\n\n' + roll_table
|
||||||
|
|
||||||
|
|
||||||
|
def generate(lap: Lap, out_path: str, lang: str = 'ru') -> tuple[str, int]:
|
||||||
|
corners = detect_corners(lap)
|
||||||
|
susp = _suspension_section(lap, corners)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
header_block(lap),
|
||||||
|
'\n## Setup',
|
||||||
|
_full_setup_table(lap),
|
||||||
|
'\n## Corner balance',
|
||||||
|
_corner_balance(lap, corners),
|
||||||
|
'\n## Tyres',
|
||||||
|
_tyre_section(lap),
|
||||||
|
'\n## Brakes',
|
||||||
|
_brake_section(lap),
|
||||||
|
]
|
||||||
|
|
||||||
|
if susp is not None:
|
||||||
|
parts += ['\n## Suspension', susp]
|
||||||
|
|
||||||
|
legend_entries = [LEGEND_GLAT, LEGEND_STEER, LEGEND_LOCK, LEGEND_SPIN, LEGEND_STEER_G_CAVEAT]
|
||||||
|
if susp is not None:
|
||||||
|
legend_entries.append(LEGEND_SUSP)
|
||||||
|
|
||||||
|
parts += [
|
||||||
|
'\n## Lap phases (avg surface temp °C)',
|
||||||
|
_lap_phases(lap),
|
||||||
|
'\n' + legend(legend_entries),
|
||||||
|
]
|
||||||
|
text = '\n'.join(parts)
|
||||||
|
prompt = load_prompt('setup', lang)
|
||||||
|
if prompt:
|
||||||
|
text += '\n\n---\n\n' + prompt
|
||||||
|
return write_report(out_path, text)
|
||||||
187
telemetry/report_technique.py
Normal file
187
telemetry/report_technique.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
from .parser import Lap, laptime_s
|
||||||
|
from .segments import detect_corners, straights, Corner, Straight
|
||||||
|
from .resample import adaptive_points, sample_at, format_trace_row
|
||||||
|
from .report_common import (
|
||||||
|
header_block, setup_summary, legend, md_table, write_report, report_path,
|
||||||
|
load_prompt,
|
||||||
|
LEGEND_DIST, LEGEND_TIME, LEGEND_SPD, LEGEND_THR_BRK, LEGEND_STEER,
|
||||||
|
LEGEND_GEAR, LEGEND_DRS, LEGEND_GLAT, LEGEND_GLON, LEGEND_FUEL, LEGEND_ERS,
|
||||||
|
LEGEND_LOCK, LEGEND_SPIN, LEGEND_TF, LEGEND_RPM, LEGEND_SLIP, LEGEND_YAW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _corner_rows(corners: list[Corner]) -> list[list[str]]:
|
||||||
|
rows = []
|
||||||
|
for c in corners:
|
||||||
|
flag_str = '—'
|
||||||
|
flags = []
|
||||||
|
if c.lockup:
|
||||||
|
flags.append('LOCK')
|
||||||
|
if c.wheelspin:
|
||||||
|
flags.append('SPIN')
|
||||||
|
if flags:
|
||||||
|
flag_str = '/'.join(flags)
|
||||||
|
brake_m = f'{c.brake_start_m:.0f}' if c.brake_start_m is not None else '—'
|
||||||
|
ft_m = f'{c.full_throttle_m:.0f}' if c.full_throttle_m is not None else '—'
|
||||||
|
rows.append([
|
||||||
|
str(c.number),
|
||||||
|
brake_m,
|
||||||
|
f'{c.v_entry:.0f}',
|
||||||
|
f'{c.v_min:.0f}',
|
||||||
|
f'{c.apex_m:.0f}',
|
||||||
|
str(c.gear_apex),
|
||||||
|
ft_m,
|
||||||
|
f'{c.v_exit:.0f}',
|
||||||
|
f'{c.brake_max:.0f}',
|
||||||
|
f'{c.g_lat_max:.2f}',
|
||||||
|
f'{c.time_s:.2f}',
|
||||||
|
f'{c.lock_pct:.1f}',
|
||||||
|
f'{c.spin_pct:.1f}',
|
||||||
|
str(int(c.coast_m)),
|
||||||
|
str(int(c.rear_tyre_temp_peak)),
|
||||||
|
flag_str,
|
||||||
|
])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _straight_rows(ss: list[Straight]) -> list[list[str]]:
|
||||||
|
rows = []
|
||||||
|
for s in ss:
|
||||||
|
rows.append([
|
||||||
|
f'{s.start_m:.0f}→{s.end_m:.0f}',
|
||||||
|
f'{s.v_max:.0f}',
|
||||||
|
'Y' if s.drs else 'N',
|
||||||
|
f'{s.time_s:.2f}',
|
||||||
|
])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _driving_cost(lap: Lap, corners: list[Corner]) -> str:
|
||||||
|
ch = lap.ch
|
||||||
|
fuel = ch.get('FuelRemaining', [])
|
||||||
|
fuel_used = (fuel[0] - fuel[-1]) if len(fuel) >= 2 else 0.0
|
||||||
|
|
||||||
|
wear_keys = ['TyreWearFrontLeft', 'TyreWearFrontRight', 'TyreWearRearLeft', 'TyreWearRearRight']
|
||||||
|
wear_lines = []
|
||||||
|
for k in wear_keys:
|
||||||
|
v = ch.get(k, [])
|
||||||
|
if len(v) >= 2:
|
||||||
|
wear_lines.append(f' {k.replace("TyreWear","")}: Δ{v[-1]-v[0]:.2f}%')
|
||||||
|
|
||||||
|
ers_spent = ch.get('ERSSpent', [])
|
||||||
|
mguk_h = ch.get('MGUKHarvested', [])
|
||||||
|
mguh_h = ch.get('MGUHHarvested', [])
|
||||||
|
ers_mj = (ers_spent[-1] / 1e6) if ers_spent else 0.0
|
||||||
|
mguk_mj = (mguk_h[-1] / 1e6) if mguk_h else 0.0
|
||||||
|
mguh_mj = (mguh_h[-1] / 1e6) if mguh_h else 0.0
|
||||||
|
|
||||||
|
lock_count = sum(1 for c in corners if c.lockup)
|
||||||
|
spin_count = sum(1 for c in corners if c.wheelspin)
|
||||||
|
worst_lock = min(corners, key=lambda c: c.lock_pct, default=None)
|
||||||
|
worst_spin = max(corners, key=lambda c: c.spin_pct, default=None)
|
||||||
|
worst_lock_str = f'T{worst_lock.number} ({worst_lock.lock_pct:.1f}%)' if worst_lock else '—'
|
||||||
|
worst_spin_str = f'T{worst_spin.number} ({worst_spin.spin_pct:.1f}%)' if worst_spin else '—'
|
||||||
|
|
||||||
|
bt_keys = ['BrakeTemperatureFrontLeft', 'BrakeTemperatureFrontRight',
|
||||||
|
'BrakeTemperatureRearLeft', 'BrakeTemperatureRearRight']
|
||||||
|
bt_lines = []
|
||||||
|
for k in bt_keys:
|
||||||
|
v = ch.get(k, [])
|
||||||
|
if v:
|
||||||
|
bt_lines.append(f' {k.replace("BrakeTemperature","")}: max {max(v):.0f} °C')
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f'- Fuel used: {fuel_used:.2f} kg',
|
||||||
|
'- Tyre wear Δ:',
|
||||||
|
*wear_lines,
|
||||||
|
f'- ERS spent: {ers_mj:.3f} MJ | MGU-K harvested: {mguk_mj:.3f} MJ | MGU-H harvested: {mguh_mj:.3f} MJ',
|
||||||
|
f'- Corners flagged LOCK: {lock_count} | SPIN: {spin_count}',
|
||||||
|
f'- Worst lock%: {worst_lock_str} | Worst spin%: {worst_spin_str}',
|
||||||
|
'- Max brake temps:',
|
||||||
|
*bt_lines,
|
||||||
|
]
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_block(lap: Lap, corners: list[Corner]) -> str:
|
||||||
|
dists = adaptive_points(lap, corners)
|
||||||
|
if not dists:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def s(channel: str) -> list[float]:
|
||||||
|
return sample_at(lap, channel, dists)
|
||||||
|
|
||||||
|
lt = s('LapTime')
|
||||||
|
spd = s('Speed')
|
||||||
|
thr = s('ThrottlePercentage')
|
||||||
|
brk = s('BrakePercentage')
|
||||||
|
steer = s('Steer')
|
||||||
|
gear = s('Gear')
|
||||||
|
drs = s('DRS')
|
||||||
|
glat = s('GForceLatitudinal')
|
||||||
|
glon = s('GForceLongitudinal')
|
||||||
|
fuel = s('FuelRemaining')
|
||||||
|
ers_raw = s('ERSSpent')
|
||||||
|
ers = [v / 1e6 for v in ers_raw]
|
||||||
|
tf_fl = s('TyreTemperatureFrontLeft')
|
||||||
|
tf_fr = s('TyreTemperatureFrontRight')
|
||||||
|
tr_rl = s('TyreTemperatureRearLeft')
|
||||||
|
tr_rr = s('TyreTemperatureRearRight')
|
||||||
|
rpm_raw = s('EngineRevs')
|
||||||
|
tf = [(tf_fl[i] + tf_fr[i]) / 2 for i in range(len(dists))]
|
||||||
|
tr = [(tr_rl[i] + tr_rr[i]) / 2 for i in range(len(dists))]
|
||||||
|
rpm = [round(rpm_raw[i] / 100) * 100 for i in range(len(dists))]
|
||||||
|
|
||||||
|
slip_fl = s('WheelSlipFrontLeft')
|
||||||
|
slip_fr = s('WheelSlipFrontRight')
|
||||||
|
slip_rl = s('WheelSlipRearLeft')
|
||||||
|
slip_rr = s('WheelSlipRearRight')
|
||||||
|
yaw_vals = s('LocalAngularVelocityY')
|
||||||
|
slip_f = [min(slip_fl[i], slip_fr[i]) / max(spd[i], 30.0) * 100.0 for i in range(len(dists))]
|
||||||
|
slip_r = [max(slip_rl[i], slip_rr[i]) / max(spd[i], 30.0) * 100.0 for i in range(len(dists))]
|
||||||
|
|
||||||
|
header = 'dist,time,spd,thr,brk,steer,gear,drs,gLat,gLon,fuel,ers,Tf,Tr,rpm,slipF,slipR,yaw'
|
||||||
|
rows = [
|
||||||
|
format_trace_row(dists[i], lt[i], spd[i], thr[i], brk[i],
|
||||||
|
steer[i], gear[i], drs[i], glat[i], glon[i], fuel[i], ers[i],
|
||||||
|
tf[i], tr[i], rpm[i], slip_f[i], slip_r[i], yaw_vals[i])
|
||||||
|
for i in range(len(dists))
|
||||||
|
]
|
||||||
|
return '```\n' + header + '\n' + '\n'.join(rows) + '\n```'
|
||||||
|
|
||||||
|
|
||||||
|
def generate(lap: Lap, out_path: str, lang: str = 'ru') -> tuple[str, int]:
|
||||||
|
corners = detect_corners(lap)
|
||||||
|
ss = straights(corners, lap)
|
||||||
|
|
||||||
|
corner_table = md_table(
|
||||||
|
['T', 'brake@m', 'Ventry', 'Vmin', 'apex@m', 'gear', 'fullThr@m', 'Vexit', 'maxBrake%', 'gLat', 'time s', 'lock%', 'spin%', 'coast m', 'Tr peak °C', 'flags'],
|
||||||
|
_corner_rows(corners),
|
||||||
|
)
|
||||||
|
straight_table = md_table(
|
||||||
|
['from→to m', 'Vmax', 'DRS', 'time s'],
|
||||||
|
_straight_rows(ss),
|
||||||
|
)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
header_block(lap),
|
||||||
|
f'\n**Setup:** {setup_summary(lap)}',
|
||||||
|
'\n## Corners',
|
||||||
|
corner_table,
|
||||||
|
'\n## Straights',
|
||||||
|
straight_table,
|
||||||
|
'\n## Driving cost (per lap)',
|
||||||
|
_driving_cost(lap, corners),
|
||||||
|
'\n## Trace',
|
||||||
|
_trace_block(lap, corners),
|
||||||
|
'\n' + legend([
|
||||||
|
LEGEND_DIST, LEGEND_TIME, LEGEND_SPD, LEGEND_THR_BRK, LEGEND_STEER,
|
||||||
|
LEGEND_GEAR, LEGEND_DRS, LEGEND_GLAT, LEGEND_GLON, LEGEND_FUEL, LEGEND_ERS,
|
||||||
|
LEGEND_TF, LEGEND_RPM, LEGEND_SLIP, LEGEND_YAW, LEGEND_LOCK, LEGEND_SPIN,
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
text = '\n'.join(parts)
|
||||||
|
prompt = load_prompt('technique', lang)
|
||||||
|
if prompt:
|
||||||
|
text += '\n\n---\n\n' + prompt
|
||||||
|
return write_report(out_path, text)
|
||||||
145
telemetry/resample.py
Normal file
145
telemetry/resample.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import bisect
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
from .parser import Lap
|
||||||
|
from .segments import Corner
|
||||||
|
|
||||||
|
_DISCRETE = {'Gear', 'DRS', 'ERSMode'}
|
||||||
|
|
||||||
|
|
||||||
|
def auto_steps(lap: Lap) -> tuple[float, float]:
|
||||||
|
dist = lap.ch.get('LapDistance', [])
|
||||||
|
diffs = [dist[i + 1] - dist[i] for i in range(len(dist) - 1) if dist[i + 1] - dist[i] > 0]
|
||||||
|
if not diffs:
|
||||||
|
return (4.0, 25.0)
|
||||||
|
med = statistics.median(diffs)
|
||||||
|
dense = max(round(med, 1), 1.0)
|
||||||
|
sparse = max(round(med * 8, 0), 20.0)
|
||||||
|
return (dense, sparse)
|
||||||
|
|
||||||
|
|
||||||
|
def adaptive_points(
|
||||||
|
lap: Lap,
|
||||||
|
corners: list[Corner],
|
||||||
|
dense_step: float | None = None,
|
||||||
|
sparse_step: float | None = None,
|
||||||
|
approach_m: float = 100.0,
|
||||||
|
) -> list[float]:
|
||||||
|
if dense_step is None or sparse_step is None:
|
||||||
|
auto_d, auto_s = auto_steps(lap)
|
||||||
|
if dense_step is None:
|
||||||
|
dense_step = auto_d
|
||||||
|
if sparse_step is None:
|
||||||
|
sparse_step = auto_s
|
||||||
|
|
||||||
|
dist = lap.ch.get('LapDistance', [])
|
||||||
|
if not dist:
|
||||||
|
return []
|
||||||
|
|
||||||
|
d_min = dist[0]
|
||||||
|
d_max = dist[-1]
|
||||||
|
|
||||||
|
# build dense/sparse bands
|
||||||
|
dense_ranges: list[tuple[float, float]] = []
|
||||||
|
for c in corners:
|
||||||
|
band_start = max(d_min, c.start_m - approach_m)
|
||||||
|
dense_ranges.append((band_start, c.end_m))
|
||||||
|
|
||||||
|
def in_dense(d: float) -> bool:
|
||||||
|
for a, b in dense_ranges:
|
||||||
|
if a <= d <= b:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
points: list[float] = [d_min]
|
||||||
|
d = d_min
|
||||||
|
while d < d_max:
|
||||||
|
step = dense_step if in_dense(d) else sparse_step
|
||||||
|
d = min(d + step, d_max)
|
||||||
|
points.append(d)
|
||||||
|
|
||||||
|
if points[-1] != d_max:
|
||||||
|
points.append(d_max)
|
||||||
|
|
||||||
|
return sorted(set(points))
|
||||||
|
|
||||||
|
|
||||||
|
def _interp(xs: list[float], ys: list[float], x: float) -> float:
|
||||||
|
if x <= xs[0]:
|
||||||
|
return ys[0]
|
||||||
|
if x >= xs[-1]:
|
||||||
|
return ys[-1]
|
||||||
|
i = bisect.bisect_left(xs, x)
|
||||||
|
x0, x1 = xs[i - 1], xs[i]
|
||||||
|
y0, y1 = ys[i - 1], ys[i]
|
||||||
|
if x1 == x0:
|
||||||
|
return y0
|
||||||
|
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
|
||||||
|
|
||||||
|
|
||||||
|
def _nearest(xs: list[float], ys: list[float], x: float) -> float:
|
||||||
|
if x <= xs[0]:
|
||||||
|
return ys[0]
|
||||||
|
if x >= xs[-1]:
|
||||||
|
return ys[-1]
|
||||||
|
i = bisect.bisect_left(xs, x)
|
||||||
|
if i == 0:
|
||||||
|
return ys[0]
|
||||||
|
if abs(xs[i] - x) < abs(xs[i - 1] - x):
|
||||||
|
return ys[i]
|
||||||
|
return ys[i - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def sample_at(lap: Lap, channel: str, dists: list[float]) -> list[float]:
|
||||||
|
src_dist = lap.ch.get('LapDistance', [])
|
||||||
|
src_vals = lap.ch.get(channel, [])
|
||||||
|
if not src_dist or not src_vals:
|
||||||
|
return [0.0] * len(dists)
|
||||||
|
|
||||||
|
discrete = channel in _DISCRETE
|
||||||
|
fn = _nearest if discrete else _interp
|
||||||
|
return [fn(src_dist, src_vals, d) for d in dists]
|
||||||
|
|
||||||
|
|
||||||
|
def _round_channel(ch: str, v: float) -> str:
|
||||||
|
if ch in ('LapTime', 'LapDistance'):
|
||||||
|
return f'{v:.0f}' if ch == 'LapDistance' else f'{v:.2f}'
|
||||||
|
if ch in ('FuelRemaining',):
|
||||||
|
return f'{v:.2f}'
|
||||||
|
if ch in ('Steer', 'ThrottlePercentage', 'BrakePercentage'):
|
||||||
|
return f'{v:.0f}'
|
||||||
|
if ch in ('Speed', 'GForceLatitudinal', 'GForceLongitudinal'):
|
||||||
|
return f'{v:.0f}'
|
||||||
|
if ch in ('Gear', 'DRS', 'ERSMode'):
|
||||||
|
return f'{int(v)}'
|
||||||
|
return f'{v:.1f}'
|
||||||
|
|
||||||
|
|
||||||
|
def format_trace_row(
|
||||||
|
dist: float,
|
||||||
|
laptime: float,
|
||||||
|
speed: float,
|
||||||
|
throttle: float,
|
||||||
|
brake: float,
|
||||||
|
steer: float,
|
||||||
|
gear: float,
|
||||||
|
drs: float,
|
||||||
|
g_lat: float,
|
||||||
|
g_lon: float,
|
||||||
|
fuel: float,
|
||||||
|
ers: float = 0.0,
|
||||||
|
tf: float = 0.0,
|
||||||
|
tr: float = 0.0,
|
||||||
|
rpm: float = 0.0,
|
||||||
|
slip_f: float = 0.0,
|
||||||
|
slip_r: float = 0.0,
|
||||||
|
yaw: float = 0.0,
|
||||||
|
) -> str:
|
||||||
|
steer_norm = 0.0 if steer == 0.0 or steer == -0.0 else steer
|
||||||
|
return (
|
||||||
|
f'{dist:.0f},{laptime:.2f},{speed:.0f},{throttle:.0f},'
|
||||||
|
f'{brake:.0f},{steer_norm:.0f},{int(gear)},{int(drs)},'
|
||||||
|
f'{g_lat:.1f},{g_lon:.1f},{fuel:.2f},{ers:.3f},'
|
||||||
|
f'{tf:.0f},{tr:.0f},{rpm:.0f},'
|
||||||
|
f'{slip_f:.1f},{slip_r:.1f},{yaw:.2f}'
|
||||||
|
)
|
||||||
152
telemetry/segments.py
Normal file
152
telemetry/segments.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .parser import Lap
|
||||||
|
|
||||||
|
STEER_ON = 6.0
|
||||||
|
BRAKE_ON = 5.0
|
||||||
|
MERGE_GAP_M = 40.0
|
||||||
|
MIN_LEN_M = 15.0
|
||||||
|
BRAKE_MARK = 20.0
|
||||||
|
FULL_THROTTLE = 98.0
|
||||||
|
LOCKUP_REL_PCT = -10.0
|
||||||
|
SPIN_REL_PCT = 8.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Corner:
|
||||||
|
number: int
|
||||||
|
start_m: float
|
||||||
|
end_m: float
|
||||||
|
brake_start_m: float | None
|
||||||
|
v_entry: float
|
||||||
|
v_min: float
|
||||||
|
apex_m: float
|
||||||
|
gear_apex: int
|
||||||
|
full_throttle_m: float | None
|
||||||
|
v_exit: float
|
||||||
|
brake_max: float
|
||||||
|
g_lat_max: float
|
||||||
|
time_s: float
|
||||||
|
lock_pct: float
|
||||||
|
spin_pct: float
|
||||||
|
coast_m: float
|
||||||
|
rear_tyre_temp_peak: float
|
||||||
|
lockup: bool
|
||||||
|
wheelspin: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Straight:
|
||||||
|
start_m: float
|
||||||
|
end_m: float
|
||||||
|
v_max: float
|
||||||
|
drs: bool
|
||||||
|
time_s: float
|
||||||
|
|
||||||
|
|
||||||
|
def _get(lap: Lap, ch: str) -> list[float]:
|
||||||
|
return lap.ch.get(ch, [])
|
||||||
|
|
||||||
|
|
||||||
|
def _indices_in_range(dist: list[float], start: float, end: float) -> list[int]:
|
||||||
|
return [i for i, d in enumerate(dist) if start <= d <= end]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_corners(lap: Lap) -> list[Corner]:
|
||||||
|
from .corner_metrics import corner_from_zone
|
||||||
|
|
||||||
|
dist = _get(lap, 'LapDistance')
|
||||||
|
steer = _get(lap, 'Steer')
|
||||||
|
brake = _get(lap, 'BrakePercentage')
|
||||||
|
speed = _get(lap, 'Speed')
|
||||||
|
gear = _get(lap, 'Gear')
|
||||||
|
throttle = _get(lap, 'ThrottlePercentage')
|
||||||
|
glat = _get(lap, 'GForceLatitudinal')
|
||||||
|
laptime = _get(lap, 'LapTime')
|
||||||
|
slip_rl = _get(lap, 'WheelSlipRearLeft')
|
||||||
|
slip_rr = _get(lap, 'WheelSlipRearRight')
|
||||||
|
slip_fl = _get(lap, 'WheelSlipFrontLeft')
|
||||||
|
slip_fr = _get(lap, 'WheelSlipFrontRight')
|
||||||
|
tyre_temp_rl = _get(lap, 'TyreTemperatureRearLeft')
|
||||||
|
tyre_temp_rr = _get(lap, 'TyreTemperatureRearRight')
|
||||||
|
|
||||||
|
n = len(dist)
|
||||||
|
if n < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cornering = [
|
||||||
|
(abs(steer[i]) > STEER_ON if i < len(steer) else False) or
|
||||||
|
(brake[i] > BRAKE_ON if i < len(brake) else False)
|
||||||
|
for i in range(n)
|
||||||
|
]
|
||||||
|
|
||||||
|
zones: list[tuple[int, int]] = []
|
||||||
|
in_zone = False
|
||||||
|
z_start = 0
|
||||||
|
for i, c in enumerate(cornering):
|
||||||
|
if c and not in_zone:
|
||||||
|
in_zone = True
|
||||||
|
z_start = i
|
||||||
|
elif not c and in_zone:
|
||||||
|
in_zone = False
|
||||||
|
zones.append((z_start, i - 1))
|
||||||
|
if in_zone:
|
||||||
|
zones.append((z_start, n - 1))
|
||||||
|
|
||||||
|
merged: list[tuple[int, int]] = []
|
||||||
|
for z in zones:
|
||||||
|
if merged and (dist[z[0]] - dist[merged[-1][1]]) < MERGE_GAP_M:
|
||||||
|
merged[-1] = (merged[-1][0], z[1])
|
||||||
|
else:
|
||||||
|
merged.append(list(z))
|
||||||
|
|
||||||
|
valid = [(a, b) for a, b in merged if (dist[b] - dist[a]) >= MIN_LEN_M]
|
||||||
|
|
||||||
|
return [
|
||||||
|
corner_from_zone(
|
||||||
|
lap, ia, ib, num,
|
||||||
|
dist, speed, brake, throttle, gear, glat, laptime,
|
||||||
|
slip_fl, slip_fr, slip_rl, slip_rr,
|
||||||
|
tyre_temp_rl, tyre_temp_rr,
|
||||||
|
)
|
||||||
|
for num, (ia, ib) in enumerate(valid, 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def straights(corners: list[Corner], lap: Lap) -> list[Straight]:
|
||||||
|
dist = _get(lap, 'LapDistance')
|
||||||
|
speed = _get(lap, 'Speed')
|
||||||
|
drs_ch = _get(lap, 'DRS')
|
||||||
|
laptime = _get(lap, 'LapTime')
|
||||||
|
|
||||||
|
if not dist:
|
||||||
|
return []
|
||||||
|
|
||||||
|
track_start = dist[0]
|
||||||
|
track_end = dist[-1]
|
||||||
|
|
||||||
|
gaps: list[tuple[float, float]] = []
|
||||||
|
if not corners:
|
||||||
|
gaps = [(track_start, track_end)]
|
||||||
|
else:
|
||||||
|
if corners[0].start_m > track_start + MIN_LEN_M:
|
||||||
|
gaps.append((track_start, corners[0].start_m))
|
||||||
|
for i in range(len(corners) - 1):
|
||||||
|
gaps.append((corners[i].end_m, corners[i + 1].start_m))
|
||||||
|
if corners[-1].end_m < track_end - MIN_LEN_M:
|
||||||
|
gaps.append((corners[-1].end_m, track_end))
|
||||||
|
|
||||||
|
result: list[Straight] = []
|
||||||
|
for start, end in gaps:
|
||||||
|
idxs = _indices_in_range(dist, start, end)
|
||||||
|
if not idxs:
|
||||||
|
continue
|
||||||
|
spds = [speed[i] for i in idxs if i < len(speed)]
|
||||||
|
v_max = max(spds) if spds else 0.0
|
||||||
|
drs_used = any(drs_ch[i] == 1.0 for i in idxs if i < len(drs_ch))
|
||||||
|
time_zone = 0.0
|
||||||
|
if laptime and idxs:
|
||||||
|
time_zone = laptime[idxs[-1]] - laptime[idxs[0]]
|
||||||
|
result.append(Straight(start_m=start, end_m=end, v_max=v_max, drs=drs_used, time_s=time_zone))
|
||||||
|
|
||||||
|
return result
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
20
tests/conftest.py
Normal file
20
tests/conftest.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from telemetry.parser import load_lap
|
||||||
|
|
||||||
|
_FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def sample_path():
|
||||||
|
return os.path.join(_FIXTURES, "sample_lap.csv")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def truncated_path():
|
||||||
|
return os.path.join(_FIXTURES, "truncated.csv")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def sample_lap(sample_path):
|
||||||
|
return load_lap(sample_path)
|
||||||
1802
tests/fixtures/sample_lap.csv
vendored
Normal file
1802
tests/fixtures/sample_lap.csv
vendored
Normal file
File diff suppressed because it is too large
Load diff
18
tests/fixtures/truncated.csv
vendored
Normal file
18
tests/fixtures/truncated.csv
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
player,v8,По умолчанию Игрок,80,2026061217003103
|
||||||
|
Game,version,date,track,car,event,laptime [s],S1 [s],S2 [s],S3 [s],S4+ [s]
|
||||||
|
F1 22,1.5,2026-06-12 14:26:16,Zandvoort,Ferrari,P1,74.097,25.856,25.757,22.484
|
||||||
|
TrackID [int],Tracklen [m],EventID [int],EventID2 [int],Weather [txt],TeamID [int],Tyre [txt],Valid [b],Pitlap [b],Online [b],Lap [int],LapsInRace [int],Fuel at Start [kg],MaxGears [int],MaxRevs [rpm],IdleRevs [rpm},TrackTemp [C],AmbientTemp [C],Windspeed [m/s],WindDirection [ ]
|
||||||
|
26,4257.009765625,1,1,Overcast,1,C2,true,false,false,9,30,14.32532787322998,9,13000.0,3499.0,25.0,17.0,0.0,0.0
|
||||||
|
FWing,RWing,OnThrottle,OffThrottle,FrontCamber,RearCamber,FrontToe,RearToe,FrontSusp,RearSusp,FrontAntiRoll,RearAntiRoll,FrontSuspH,RearSuspH,BrakePressure,BrakeBias,FLTyrePressure,FRTyrePressure,RLTyrePressure,RRTyrePressure,Ballast,FuelLoad
|
||||||
|
29.0,36.0,62.0,50.0,-2.7,-1.4,0.07,0.32,6.0,1.0,5.0,2.0,4.0,6.0,100.0,50.0,23.75,23.75,21.25,21.25,6.0,20.0,
|
||||||
|
LapDistance [m],TotalDistance [m],LapTime [s],Sector1Time [s],Sector2Time [s],Speed [km/h],EngineRevs [rpm],ThrottlePercentage [%],BrakePercentage [%],Steer [%],Clutch [%],Gear [int],DRS [0/1],CanUseDRS [0/1],KERSLevel [J],EngineTemperature [C],RacePosition [int],Sector [int],InPits [int],FuelRemaining [kg],CurrentFlag [int],CurrentLapInvalid [int],X [m],Y [m],Z [m],GForceLatitudinal [G],GForceLongitudinal [G],GForceVertical [G],WheelSpeedRearLeft [km/h],WheelSpeedRearRight [km/h],WheelSpeedFrontLeft [km/h],WheelSpeedFrontRight [km/h],BrakeTemperatureRearLeft [C],BrakeTemperatureRearRight [C],BrakeTemperatureFrontLeft [C],BrakeTemperatureFrontRight [C],TyreTemperatureRearLeft [C],TyreTemperatureRearRight [C],TyreTemperatureFrontLeft [C],TyreTemperatureFrontRight [C],TyreWearRearLeft [%],TyreWearRearRight [%],TyreWearFrontLeft [%],TyreWearFrontRight [%],SuspensionPositionRearLeft [m],SuspensionPositionRearRight [m],SuspensionPositionFrontLeft [m],SuspensionPositionFrontRight [m],SuspensionVelocityRearLeft [m/s],SuspensionVelocityRearRight [m/s],SuspensionVelocityFrontLeft [m/s],SuspensionVelocityFrontRight [m/s],SuspensionAccelerationRearLeft [m/s^2],SuspensionAccelerationRearRight [m/s^2],SuspensionAccelerationFrontLeft [m/s^2],SuspensionAccelerationFrontRight [m/s^2],MGUKHarvested [J],MGUHHarvested [J],ERSSpent [J],ERSMode [int],FuelMixMode [int],Yaw [rad],Roll [rad],Pitch [rad],TyreCarcassTemperatureRearLeft [C],TyreCarcassTemperatureRearRight [C],TyreCarcassTemperatureFrontLeft [C],TyreCarcassTemperatureFrontRight [C],TyrePressureRearLeft [PSI],TyrePressureRearRight [PSI],TyrePressureFrontLeft [PSI],TyrePressureFrontRight [PSI],WheelSlipRearLeft [%],WheelSlipRearRight [%],WheelSlipFrontLeft [%],WheelSlipFrontRight [%],Torque [Nm],Handbrake [%],RearLeftInside [C],RearLeftMiddle [C],RearLeftOutside [C],RearRightInside [C],RearRightMiddle [C],RearRightOutside [C],FrontLeftInside [C],FrontLeftMiddle [C],FrontLeftOutside [C],FrontRightInside [C],FrontRightMiddle [C],FrontRightOutside [C],WoldSpeedX [km/h],WoldSpeedY [km/h],WoldSpeedZ [km/h],LocalAngularVelocityX [rad/s],LocalAngularVelocityY [rad/s],LocalAngularVelocityZ [rad/s],IcePower [W],MgukPower [W],FrontRideHeight [m],RearRideHeight [m],LoadRearLeft [N],LoadRearRight [N],LoadFrontLeft [N],LoadFrontRight [N],Extra1 [m],Extra2 [],Extra3 [],Extra4 []
|
||||||
|
1.4414062,34060.637,0.016,0.0,0.0,300.92316,10391.0,100.0,0.0,-0.0,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.325328,0.0,0.0,-336.26627,4.7674646,-94.26318,0.0028072752,0.10456319,-0.011351893,304.12772,304.15137,301.32764,301.32925,488.0,487.0,276.0,275.0,89.0,88.0,84.0,84.0,15.557972,14.019221,14.54948,14.577653,0.044921756,0.044959016,0.05015564,0.04989115,13.324485,2.273029,6.3153086,-1.5265082,-209.01431,-76.48049,-174.01189,-530.8796,339711.06,1181101.8,1522291.8,2.0,1.0,2.7680078,0.0024861791,0.0027934313,97.0,94.0,101.0,100.0,21.548914,21.363981,24.308775,24.247131,1.0649066,1.0727661,0.13441011,0.1349476,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.501799,-77.82602,77.82602,-0.0041499594,-0.00121027,-0.01847571,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
5.6367188,34064.832,0.066,0.0,0.0,301.04617,10395.0,100.0,0.0,-0.0,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.324385,0.0,0.0,-335.2478,4.766578,-96.861435,0.003899289,0.10467015,-0.07305323,304.24542,304.27036,301.45407,301.45566,486.0,486.0,275.0,274.0,89.0,88.0,84.0,84.0,15.557972,14.019221,14.54948,14.577653,0.044754434,0.04498487,0.05009188,0.04989038,-6.3436623,-3.7458177,-1.7301099,-0.943879,-159.48404,-541.7071,21.575508,-110.34301,0.0,0.0,0.0,2.0,1.0,2.7679937,0.0028006455,0.0032486916,97.0,94.0,101.0,100.0,21.548693,21.363857,24.30833,24.246716,1.062713,1.0709951,0.135496,0.13602313,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.516088,-77.85711,77.85711,-0.009516132,-0.0020480293,-0.009078983,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
8.4375,34067.633,0.1,0.0,0.0,301.22943,10402.0,100.0,0.0,-0.0,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.322968,0.0,0.0,-333.71753,4.7632313,-100.76466,0.008000466,0.1016143,-0.120484605,304.4287,304.45337,301.63458,301.63666,485.0,484.0,273.0,272.0,89.0,88.0,84.0,84.0,15.557972,14.019221,14.54948,14.577653,0.04449996,0.044746444,0.04983968,0.0496384,-2.8749735,-1.54717,-5.5946274,-1.8339309,244.08078,109.90586,165.17023,323.069,0.0,0.0,0.0,2.0,1.0,2.7679603,0.0033536605,0.0039516687,97.0,94.0,101.0,100.0,21.54835,21.36367,24.30766,24.24609,1.0620694,1.0702553,0.13449462,0.13518351,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.536188,-77.90386,77.90386,-0.0102846,-0.0018831207,-0.011283251,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
12.636719,34071.832,0.15,0.0,0.0,301.3515,10405.0,100.0,0.0,-0.0,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.322022,0.0,0.0,-332.69583,4.759495,-103.37078,-0.0033098636,0.10347223,-0.07439932,304.5412,304.57172,301.74704,301.75604,484.0,483.0,272.0,271.0,89.0,88.0,84.0,84.0,15.557972,14.019221,14.54948,14.577653,0.04448764,0.044727005,0.050004426,0.049797866,0.18876602,3.3808506,2.2792327,6.1550646,66.62639,473.83743,-316.5041,165.91231,0.0,0.0,0.0,2.0,1.0,2.7679493,0.003717031,0.004365802,97.0,94.0,101.0,100.0,21.548122,21.363546,24.307213,24.24567,1.0584617,1.0685886,0.13125251,0.13423994,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.551609,-77.9342,77.9342,0.003345168,-0.0013961294,-0.009906127,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
15.433594,34074.63,0.183,0.0,0.0,301.53012,10412.0,100.0,0.0,-0.0,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.320605,0.0,0.0,-331.16367,4.7529416,-107.278366,0.0054911603,0.10222452,0.01249686,304.73245,304.75507,301.93707,301.94464,482.0,481.0,270.0,269.0,89.0,88.0,84.0,84.0,15.557972,14.019221,14.54948,14.577653,0.044988412,0.04496178,0.050339177,0.05027435,5.356213,1.2023151,15.818361,10.042119,-484.30725,-142.46996,501.65784,-47.178104,0.0,0.0,0.0,2.0,1.0,2.7679214,0.0038654762,0.0045741796,97.0,94.0,101.0,100.0,21.547775,21.363361,24.306541,24.245045,1.0620271,1.0695267,0.13496232,0.13747232,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.571978,-77.97949,77.97949,0.0051841117,-0.0018579913,-0.0045912713,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
19.636719,34078.832,0.233,0.0,0.0,301.64865,10416.0,100.0,0.0,-0.0,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.319661,0.0,0.0,-330.14267,4.7486176,-109.8822,-0.018588213,0.09899262,0.0044467063,304.8604,304.88666,302.0442,302.06235,481.0,480.0,269.0,268.0,89.0,88.0,84.0,84.0,15.557972,14.019221,14.54948,14.577653,0.045223925,0.045222133,0.050232194,0.050355557,12.730604,5.94785,-7.3358607,-0.54328966,680.08307,-221.74287,-494.99445,-356.54153,0.0,0.0,0.0,2.0,1.0,2.7679126,0.003786463,0.0044716597,97.0,94.0,101.0,100.0,21.547544,21.363237,24.306095,24.244629,1.0647341,1.0734347,0.13112372,0.1371433,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.587973,-78.00859,78.00859,-0.010520617,-2.3241832E-5,0.007820944,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
22.4375,34081.633,0.267,0.0,0.0,301.82678,10422.0,100.0,0.0,-0.0,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.318245,0.0,0.0,-328.60983,4.741858,-113.79107,-0.0069472967,0.10128305,-0.07116786,305.02832,305.04843,302.21487,302.2431,479.0,479.0,267.0,266.0,89.0,88.0,84.0,84.0,15.557972,14.019221,14.54948,14.577653,0.045175612,0.044876844,0.049928077,0.050548933,-4.7920732,-7.4434476,-10.124221,2.1159775,40.29982,59.541367,-298.7022,-281.96964,0.0,0.0,0.0,2.0,1.0,2.7679057,0.0030984546,0.0039668083,97.0,94.0,101.0,100.0,21.547203,21.363049,24.305424,24.244003,1.0607195,1.0673826,0.12858017,0.13793279,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.608608,-78.05361,78.05361,-0.012827186,-7.5455604E-4,0.02776532,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
26.644531,34085.84,0.317,0.0,0.0,301.94205,10426.0,100.0,0.0,-0.0,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.3173,0.0,0.0,-327.58688,4.736678,-116.39905,-0.02532983,0.09749339,0.009995961,305.1486,305.16315,302.33847,302.37128,478.0,478.0,266.0,265.0,89.0,88.0,84.0,85.0,15.557972,14.019221,14.54948,14.577653,0.04545699,0.044665035,0.049997028,0.050893392,9.305988,-4.5580263,10.266149,14.061531,105.73201,213.421,982.1091,449.053,0.0,0.0,0.0,2.0,1.0,2.767913,0.001903173,0.00283885,97.0,94.0,101.0,100.0,21.546976,21.362925,24.304976,24.243586,1.0619756,1.0667967,0.13129376,0.14215888,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.623955,-78.08197,78.08197,-0.0035325303,0.001580156,0.037603874,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
29.449219,34088.645,0.35,0.0,0.0,302.11148,10433.0,100.0,0.0,-0.042444706,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.315882,0.0,0.0,-326.05133,4.730385,-120.314224,-0.0048558465,0.093519635,0.22681257,305.34058,305.37436,302.50824,302.5387,477.0,476.0,264.0,264.0,89.0,88.0,84.0,85.0,15.557972,14.019221,14.54948,14.577653,0.04621003,0.04581786,0.051095285,0.05139819,20.420458,31.740784,16.691376,11.387646,171.71814,263.5816,-493.2345,193.50179,0.0,0.0,0.0,2.0,1.0,2.7679648,4.8967556E-4,0.0010998249,97.0,94.0,101.0,100.0,21.546635,21.36274,24.304302,24.24296,1.068846,1.0800282,0.13133223,0.14141345,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.639637,-78.12649,78.12649,0.0101466235,0.0016523275,0.02976934,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
33.65625,34092.85,0.4,0.0,0.0,302.2241,10437.0,100.0,0.0,-0.043604743,0.0,8.0,1.0,0.0,0.0,117.0,1.0,0.0,0.0,14.314938,0.0,0.0,-325.0279,4.729065,-122.92378,7.542996E-4,0.095818505,0.20318285,305.4571,305.4961,302.63306,302.658,476.0,475.0,263.0,262.0,89.0,89.0,85.0,85.0,15.560709,14.022046,14.54948,14.577653,0.04659386,0.046193358,0.05145239,0.051487185,3.0004277,7.5285163,7.44506,-1.6512841,-1017.33124,-445.06854,-389.09763,-516.9904,0.0,0.0,0.0,2.0,1.0,2.7680137,1.4408563E-4,6.15716E-4,97.0,94.0,101.0,100.0,21.546415,21.362616,24.30386,24.24255,1.0697355,1.0826403,0.13531771,0.1435675,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-30.6465,-78.15744,78.15744,0.008225428,0.0029687465,0.00101771,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,50.0
|
||||||
|
46
tests/test_parser.py
Normal file
46
tests/test_parser.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import pytest
|
||||||
|
from telemetry.parser import load_lap, laptime_s, sector_times
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_metadata(sample_lap):
|
||||||
|
assert sample_lap.session["track"] == "Zandvoort"
|
||||||
|
assert sample_lap.session["car"] == "Ferrari"
|
||||||
|
assert sample_lap.track["Lap"] == "7"
|
||||||
|
assert sample_lap.track["Tyre"] == "C2"
|
||||||
|
assert sample_lap.n > 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_zero_channels_dropped(sample_lap):
|
||||||
|
assert "Torque" not in sample_lap.ch
|
||||||
|
assert "IcePower" not in sample_lap.ch
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_channel_present(sample_lap):
|
||||||
|
assert "Speed" in sample_lap.ch
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_trailing_comma_handled(sample_lap):
|
||||||
|
assert "FuelLoad" in sample_lap.setup
|
||||||
|
assert "" not in sample_lap.setup
|
||||||
|
|
||||||
|
|
||||||
|
def test_utf8_loads_cleanly(sample_path):
|
||||||
|
# implicit — load_lap opens with encoding='utf-8'; raises on decode error
|
||||||
|
lap = load_lap(sample_path)
|
||||||
|
assert lap.n > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_file_loads(truncated_path):
|
||||||
|
lap = load_lap(truncated_path)
|
||||||
|
assert 1 <= lap.n <= 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_laptime_s(sample_lap):
|
||||||
|
lt = laptime_s(sample_lap)
|
||||||
|
assert 70.0 < lt < 80.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sector_times_sum_to_laptime(sample_lap):
|
||||||
|
lt = laptime_s(sample_lap)
|
||||||
|
s1, s2, s3 = sector_times(sample_lap)
|
||||||
|
assert abs((s1 + s2 + s3) - lt) < 0.1
|
||||||
87
tests/test_rename.py
Normal file
87
tests/test_rename.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import pytest
|
||||||
|
from telemetry.rename import lap_token_present, insert_lap_token, read_lap_number, rename_unprocessed
|
||||||
|
|
||||||
|
|
||||||
|
# --- insert_lap_token ---
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("filename,lap,expected", [
|
||||||
|
# float token found → insert before it
|
||||||
|
("zandvoort_P_74.152_ferrari.csv", 8, "zandvoort_P_L8_74.152_ferrari.csv"),
|
||||||
|
# single non-float token → append
|
||||||
|
("weirdname.csv", 3, "weirdname_L3.csv"),
|
||||||
|
# two non-float tokens, no float → insert at index 2 (after 2nd token)
|
||||||
|
("track_session.csv", 5, "track_session_L5.csv"),
|
||||||
|
# extension preserved
|
||||||
|
("a_b_1.5.csv", 2, "a_b_L2_1.5.csv"),
|
||||||
|
])
|
||||||
|
def test_insert_lap_token(filename, lap, expected):
|
||||||
|
assert insert_lap_token(filename, lap) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# --- lap_token_present ---
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stem,expected", [
|
||||||
|
("a_L8_b", True),
|
||||||
|
("a_L8_b.csv", True), # stem may include extension (function splits on '_')
|
||||||
|
("a_b", False),
|
||||||
|
("a_b.csv", False),
|
||||||
|
("a_L_b", False), # L without digits
|
||||||
|
("L8", True),
|
||||||
|
("notoken", False),
|
||||||
|
])
|
||||||
|
def test_lap_token_present(stem, expected):
|
||||||
|
assert lap_token_present(stem) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# --- read_lap_number ---
|
||||||
|
|
||||||
|
def test_read_lap_number_sample(sample_path):
|
||||||
|
assert read_lap_number(sample_path) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_lap_number_truncated(truncated_path):
|
||||||
|
assert read_lap_number(truncated_path) == 9
|
||||||
|
|
||||||
|
|
||||||
|
# --- rename_unprocessed ---
|
||||||
|
|
||||||
|
def test_rename_unprocessed_renames(tmp_path, sample_path):
|
||||||
|
src = str(tmp_path / "zandvoort_P_74.074_ferrari.csv")
|
||||||
|
shutil.copy(sample_path, src)
|
||||||
|
results = rename_unprocessed([src])
|
||||||
|
assert len(results) == 1
|
||||||
|
r = results[0]
|
||||||
|
assert r.status == "renamed"
|
||||||
|
assert r.new is not None and "L7" in r.new
|
||||||
|
assert os.path.exists(tmp_path / r.new)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_unprocessed_skips_already_tagged(tmp_path, sample_path):
|
||||||
|
# rename first so it has L7 in name
|
||||||
|
src = str(tmp_path / "zandvoort_P_74.074_ferrari.csv")
|
||||||
|
shutil.copy(sample_path, src)
|
||||||
|
rename_unprocessed([src])
|
||||||
|
# now run on the directory — only the renamed file is present, should be skipped
|
||||||
|
results = rename_unprocessed([str(tmp_path)])
|
||||||
|
assert all(r.status == "skipped" for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_unprocessed_error_target_exists(tmp_path, sample_path):
|
||||||
|
src = str(tmp_path / "zandvoort_P_74.074_ferrari.csv")
|
||||||
|
shutil.copy(sample_path, src)
|
||||||
|
# pre-create the destination so rename sees a conflict
|
||||||
|
dest = str(tmp_path / "zandvoort_P_L7_74.074_ferrari.csv")
|
||||||
|
shutil.copy(sample_path, dest)
|
||||||
|
results = rename_unprocessed([src])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].status == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_unprocessed_dir_target(tmp_path, sample_path):
|
||||||
|
src = str(tmp_path / "zandvoort_P_74.074_ferrari.csv")
|
||||||
|
shutil.copy(sample_path, src)
|
||||||
|
results = rename_unprocessed([str(tmp_path)])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].status == "renamed"
|
||||||
73
tests/test_reports.py
Normal file
73
tests/test_reports.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import telemetry.report_technique as rt
|
||||||
|
import telemetry.report_setup as rs
|
||||||
|
import telemetry.report_compare as rc
|
||||||
|
import telemetry.report_race as rr
|
||||||
|
|
||||||
|
|
||||||
|
def _check(result, path):
|
||||||
|
abs_path, tokens = result
|
||||||
|
assert tokens > 0
|
||||||
|
assert os.path.isabs(abs_path)
|
||||||
|
assert os.path.isfile(abs_path)
|
||||||
|
assert os.path.getsize(abs_path) > 0
|
||||||
|
assert abs_path == os.path.abspath(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_technique_generate(sample_lap, tmp_path):
|
||||||
|
out = str(tmp_path / "out_technique.md")
|
||||||
|
result = rt.generate(sample_lap, out)
|
||||||
|
_check(result, out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_generate(sample_lap, tmp_path):
|
||||||
|
out = str(tmp_path / "out_setup.md")
|
||||||
|
result = rs.generate(sample_lap, out)
|
||||||
|
_check(result, out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_generate_same_lap(sample_lap, tmp_path):
|
||||||
|
out = str(tmp_path / "out_compare.md")
|
||||||
|
result = rc.generate([sample_lap, sample_lap], out)
|
||||||
|
_check(result, out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_race_generate_same_lap(sample_lap, tmp_path):
|
||||||
|
out = str(tmp_path / "out_race.md")
|
||||||
|
result = rr.generate([sample_lap, sample_lap], out)
|
||||||
|
_check(result, out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_technique_output_contains_corners(sample_lap, tmp_path):
|
||||||
|
out = str(tmp_path / "tech_corners.md")
|
||||||
|
rt.generate(sample_lap, out)
|
||||||
|
with open(out, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
assert "## Corners" in text
|
||||||
|
assert "## Trace" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_output_contains_setup(sample_lap, tmp_path):
|
||||||
|
out = str(tmp_path / "setup_check.md")
|
||||||
|
rs.generate(sample_lap, out)
|
||||||
|
with open(out, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
assert "## Setup" in text
|
||||||
|
assert "## Tyres" in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegressionReports:
|
||||||
|
"""Regression: report generators must not crash on valid single-lap input."""
|
||||||
|
|
||||||
|
def test_technique_no_exception(self, sample_lap, tmp_path):
|
||||||
|
rt.generate(sample_lap, str(tmp_path / "r_tech.md"))
|
||||||
|
|
||||||
|
def test_setup_no_exception(self, sample_lap, tmp_path):
|
||||||
|
rs.generate(sample_lap, str(tmp_path / "r_setup.md"))
|
||||||
|
|
||||||
|
def test_compare_no_exception(self, sample_lap, tmp_path):
|
||||||
|
rc.generate([sample_lap], str(tmp_path / "r_cmp.md"))
|
||||||
|
|
||||||
|
def test_race_no_exception(self, sample_lap, tmp_path):
|
||||||
|
rr.generate([sample_lap], str(tmp_path / "r_race.md"))
|
||||||
52
tests/test_resample.py
Normal file
52
tests/test_resample.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import pytest
|
||||||
|
from telemetry.resample import auto_steps, adaptive_points, sample_at
|
||||||
|
from telemetry.segments import detect_corners
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_steps_ranges(sample_lap):
|
||||||
|
dense, sparse = auto_steps(sample_lap)
|
||||||
|
assert 1.0 <= dense <= 5.0
|
||||||
|
assert sparse >= 20.0
|
||||||
|
assert dense < sparse
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_points_monotone(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
pts = adaptive_points(sample_lap, corners)
|
||||||
|
assert all(pts[i] < pts[i + 1] for i in range(len(pts) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_points_bounds(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
dist = sample_lap.ch["LapDistance"]
|
||||||
|
pts = adaptive_points(sample_lap, corners)
|
||||||
|
assert pts[0] == dist[0]
|
||||||
|
assert pts[-1] == dist[-1]
|
||||||
|
assert all(dist[0] <= p <= dist[-1] for p in pts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_at_length(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
dists = adaptive_points(sample_lap, corners)
|
||||||
|
vals = sample_at(sample_lap, "Speed", dists)
|
||||||
|
assert len(vals) == len(dists)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_at_endpoint_clamping(sample_lap):
|
||||||
|
dist = sample_lap.ch["LapDistance"]
|
||||||
|
speed = sample_lap.ch["Speed"]
|
||||||
|
# query exactly at and beyond the recorded endpoints
|
||||||
|
before = dist[0] - 10.0
|
||||||
|
after = dist[-1] + 10.0
|
||||||
|
vals = sample_at(sample_lap, "Speed", [before, dist[0], dist[-1], after])
|
||||||
|
assert vals[0] == speed[0]
|
||||||
|
assert vals[1] == speed[0]
|
||||||
|
assert vals[2] == speed[-1]
|
||||||
|
assert vals[3] == speed[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_at_missing_channel_returns_zeros(sample_lap):
|
||||||
|
dist = sample_lap.ch["LapDistance"]
|
||||||
|
dists = [dist[0], dist[-1]]
|
||||||
|
vals = sample_at(sample_lap, "NonExistentChannel", dists)
|
||||||
|
assert vals == [0.0, 0.0]
|
||||||
57
tests/test_segments.py
Normal file
57
tests/test_segments.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import dataclasses
|
||||||
|
import pytest
|
||||||
|
from telemetry.parser import load_lap
|
||||||
|
from telemetry.segments import detect_corners, straights
|
||||||
|
|
||||||
|
|
||||||
|
def test_zandvoort_corner_count(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
assert 10 <= len(corners) <= 13
|
||||||
|
|
||||||
|
|
||||||
|
def test_corners_numbered_sequentially(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
assert [c.number for c in corners] == list(range(1, len(corners) + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_corner_start_m_strictly_increasing(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
starts = [c.start_m for c in corners]
|
||||||
|
assert all(starts[i] < starts[i + 1] for i in range(len(starts) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_corner_vmin_le_ventry(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
for c in corners:
|
||||||
|
assert c.v_min <= c.v_entry + 1.0, f"T{c.number}: v_min={c.v_min} > v_entry={c.v_entry}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_corner_lock_pct_nonpositive(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
for c in corners:
|
||||||
|
assert c.lock_pct <= 0.0, f"T{c.number}: lock_pct={c.lock_pct}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_corner_spin_pct_nonnegative(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
for c in corners:
|
||||||
|
assert c.spin_pct >= 0.0, f"T{c.number}: spin_pct={c.spin_pct}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_corner_is_frozen(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)):
|
||||||
|
corners[0].number = 99
|
||||||
|
|
||||||
|
|
||||||
|
def test_straights_nonempty(sample_lap):
|
||||||
|
corners = detect_corners(sample_lap)
|
||||||
|
ss = straights(corners, sample_lap)
|
||||||
|
assert len(ss) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_lap_corners_empty(truncated_path):
|
||||||
|
lap = load_lap(truncated_path)
|
||||||
|
corners = detect_corners(lap)
|
||||||
|
# very short lap — may have few or no detectable corners; must not crash
|
||||||
|
assert isinstance(corners, list)
|
||||||
Loading…
Add table
Reference in a new issue