diff --git a/.gitignore b/.gitignore index 2aed45e8..1a7f12b7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ node_modules/ # Local deployment backups backup/ + +# TuneWizard: local flight logs for manual checks +TuneWizard/logs/ diff --git a/TuneWizard/Readme.md b/TuneWizard/Readme.md new file mode 100644 index 00000000..cd6506aa --- /dev/null +++ b/TuneWizard/Readme.md @@ -0,0 +1,26 @@ +## Мастер настройки + +Пошаговый мастер настройки Copter по одному логу `.bin`: проверка лога, спектр гироскопов, гармонический notch, низкочастотные фильтры, PID по осям, сводка и файл параметров. Объединяет расчёты FilterReview и PIDReview; оригинальные инструменты не изменяет. + +Дизайн: `docs/plans/2026-10-23-tune-wizard-design.md`. Планы этапов — в `docs/plans/`. + +### Запуск + +Из корня репозитория: `python -m http.server --bind 127.0.0.1`, затем `http://127.0.0.1:8000/TuneWizard/`. + +### Структура + +- `index.html`, `wizard.js` — страница, состояние, навигация по шагам, графики Plotly в цветах темы. +- `core/` — математика и проверки без DOM; те же файлы гоняются под Node: `node --test tests/tunewizard.test.cjs`. +- `logs/` — локальные логи и скриншоты для проверок, в репозиторий не попадают. + +### Проверки + +- `npm run lint` — ESLint по странице и скриптам. +- `npm test` — тесты ядра (вместе с остальными тестами репозитория). +- `npm run test:tunewizard` — smoke-прогон шага 0 в Chrome на реальном логе (см. `tests/README.md`). + +### Готовность + +- Этап 1: каркас, темы, шаг 0 (лог и проверки) — готово. +- Этапы 2–6: спектр и notch, фильтры, PID, сводка, лендинг — в работе. diff --git a/TuneWizard/core/checks.js b/TuneWizard/core/checks.js new file mode 100644 index 00000000..14dd0e9e --- /dev/null +++ b/TuneWizard/core/checks.js @@ -0,0 +1,161 @@ +// Проверки пригодности лога (шаг 0). Чистые функции без DOM. +// Источники порогов: [wiki] — ardupilot.org, [код] — исходники ArduPilot, [наше] — эвристика, у ArduPilot числа нет. + +const check_rules = { + vibe_warn: 30, // м/с²: выше — возможны проблемы [wiki, Measuring Vibration] + vibe_fail: 60, // м/с²: выше — почти наверняка проблемы [wiki, Measuring Vibration] + hover_min_s: 30, // с: минимальное висение для усреднения спектра [wiki, Initial tuning flight] + hover_throttle_tolerance: 0.05, // допустимое расхождение MOT_THST_HOVER и среднего газа [наше] + hover_learn_min: 0.125, // нижний предел обучения MOT_THST_HOVER [код, AP_MotorsMulticopter.h AP_MOTORS_THST_HOVER_MIN] + hover_learn_max: 0.6875, // верхний предел обучения MOT_THST_HOVER [код, AP_MotorsMulticopter.h AP_MOTORS_THST_HOVER_MAX] + hover_learn_s: 30, // с висения для обучения MOT_THST_HOVER: постоянная времени 10 с, ~95 % за 30 с [код, AP_MOTORS_THST_HOVER_TC] + log_bitmask_pid: 12, // номер бита PID в LOG_BITMASK [код, ArduCopter/defines.h MASK_LOG_PID] + log_bitmask_imu_raw: 19, // номер бита IMU_RAW [код, MASK_LOG_IMU_RAW] +} + +// info — из log_info, params — из log_params, vibe — из vibe_summary (может быть null). +// Возвращает список { id, status: "ok" | "warn" | "fail", title, text, fix }. +function run_checks(info, params, vibe) { + const checks = [] + const add = (id, status, title, text, fix) => checks.push({ id, status, title, text, fix: fix ?? null }) + const m = info.messages + const r = check_rules + + // Аппарат + if (info.vehicle === "Copter") { + add("vehicle", "ok", "Аппарат", "Copter, " + (info.fw_string ?? "версия неизвестна") + (info.board ? ", " + info.board : "")) + } else { + add("vehicle", "fail", "Аппарат", "Лог от " + (info.vehicle ?? "неизвестного аппарата") + "; мастер поддерживает только Copter", "Загрузите лог ArduCopter") + } + + // Сообщения PID + const pid_fix = "Включите бит PID (" + r.log_bitmask_pid + ") в LOG_BITMASK и перелетите" + if (m.pid) { + add("pid_log", "ok", "Сообщения PID", "PIDR/PIDP/PIDY есть: доступны компоненты P/I/D/FF, отфильтрованные цель и ошибка") + } else if (m.rate) { + add("pid_log", "warn", "Сообщения PID", "Есть только RATE: на шаге 4 не будет компонент P/I/D", pid_fix) + } else { + add("pid_log", "fail", "Сообщения PID", "Нет ни PID, ни RATE: шаг 4 недоступен", pid_fix) + } + + // Данные гироскопа + if (m.gyro_raw || m.gyro_batch) { + add("gyro_log", "ok", "Данные гироскопа", "Есть " + (m.gyro_raw ? "raw (GYR)" : "batch (ISBH/ISBD)") + ": шаги 1–3 доступны") + } else { + add("gyro_log", "fail", "Данные гироскопа", "Нет ни raw (GYR), ни batch (ISBH/ISBD): шаги 1–3 недоступны", + "Batch: INS_LOG_BAT_MASK = 1, INS_LOG_BAT_OPT = 4. Raw (нужна быстрая плата, обычно H7): бит IMU_RAW (" + r.log_bitmask_imu_raw + ") в LOG_BITMASK, INS_RAW_LOG_OPT = 8") + } + + // До/после фильтров + if (m.gyro_raw) { + const opt = params.get("INS_RAW_LOG_OPT") ?? 0 + if (opt & (1 << 3)) { + add("pre_post", "ok", "До/после фильтров", "Raw-лог до и после фильтров: оценку «станет» можно сверить с реальными данными") + } else if (opt & (1 << 2)) { + add("pre_post", "warn", "До/после фильтров", "Raw-лог только после фильтров: спектра до фильтров и оценки «станет» не будет", + "Добавьте бит 3 (+8) к INS_RAW_LOG_OPT и снимите бит 2 (−4): логирование до и после фильтров") + } else { + add("pre_post", "ok", "До/после фильтров", "Raw-лог только до фильтров: оценка «станет» строится расчётом, реальных данных после фильтров нет") + } + } else if (m.gyro_batch) { + const opt = params.get("INS_LOG_BAT_OPT") ?? 0 + if (opt & (1 << 2)) { + add("pre_post", "ok", "До/после фильтров", "Batch-лог до и после фильтров: оценку «станет» можно сверить с реальными данными") + } else if (opt & (1 << 1)) { + add("pre_post", "warn", "До/после фильтров", "Batch-лог только после фильтров: спектра до фильтров и оценки «станет» не будет", + "Добавьте бит 2 (+4) к INS_LOG_BAT_OPT и снимите бит 1 (−2): логирование до и после фильтров") + } else { + add("pre_post", "ok", "До/после фильтров", "Batch-лог только до фильтров: оценка «станет» строится расчётом, реальных данных после фильтров нет") + } + } + + // Источник трекинга notch + if (m.esc) { + add("tracking", "ok", "Источник трекинга notch", "ESC-телеметрия (ESC): лучший источник, сам подстраивается под нагрузку") + } else if (m.rpm) { + add("tracking", "ok", "Источник трекинга notch", "Датчик оборотов (RPM)") + } else { + add("tracking", "warn", "Источник трекинга notch", "Нет ESC-телеметрии и RPM: доступен только трекинг по газу (INS_HNTCH_MODE = 1)", + "Если регуляторы умеют телеметрию или двунаправленный DShot — включите, это точнее") + } + + // Полёт + if (info.flight == null) { + if (info.throttle_source == null) { + add("flight", "fail", "Полёт", "Газ не определить: в логе нет CTUN и RATE") + } else { + // Окно полёта схлопывается, если ненулевой газ короче отступов throttle_window + add("flight", "fail", "Полёт", "Ненулевого газа нет или он короче 3 с: полёта в логе нет") + } + } else if (info.flight.duration_s < r.hover_min_s) { + add("flight", "warn", "Полёт", "Полёт с газом " + Math.round(info.flight.duration_s) + " с: мало для усреднения спектра", "Висение не меньше " + r.hover_min_s + " с без ввода") + } else { + const mean_text = (info.flight.mean_throttle != null) ? ", средний газ " + info.flight.mean_throttle.toFixed(2) : "" + add("flight", "ok", "Полёт", "Полёт с газом " + Math.round(info.flight.duration_s) + " с" + mean_text) + } + + // Вибрации + if (vibe == null) { + add("vibe", "warn", "Вибрации", "Сообщений VIBE нет: уровень вибраций не проверить") + } else { + if (!Number.isFinite(vibe.level)) { + add("vibe", "warn", "Вибрации", "Уровень вибраций не определён (нет корректных данных VIBE)") + } else { + // Одна десятичная: 59.6 рядом с порогом 60 не должно читаться как «60» + const text = "Уровень (95-й процентиль) " + vibe.level.toFixed(1) + " м/с², максимум " + vibe.max.toFixed(1) + " м/с²" + if (vibe.level >= r.vibe_fail) { + add("vibe", "fail", "Вибрации", text + ": выше " + r.vibe_fail + " — почти наверняка проблемы", "Проверьте крепление автопилота, балансировку пропеллеров и моторов") + } else if (vibe.level >= r.vibe_warn) { + add("vibe", "warn", "Вибрации", text + ": выше " + r.vibe_warn + " — возможны проблемы", "Проверьте крепление автопилота и балансировку пропеллеров") + } else { + add("vibe", "ok", "Вибрации", text) + } + } + if (vibe.clip > 0) { + add("clipping", "warn", "Clipping", vibe.clip + " событий clipping акселерометра", "Проверьте крепление автопилота и балансировку пропеллеров") + } else { + add("clipping", "ok", "Clipping", "Событий clipping нет") + } + } + + // Газ висения против MOT_THST_HOVER. Расхождение считается не к самому среднему газу, а к тому значению, + // которое обучение вообще может дать: ArduPilot зажимает MOT_THST_HOVER в пределах hover_learn_min…hover_learn_max + const hover = params.get("MOT_THST_HOVER") + const mean = info.flight?.mean_throttle ?? null + if ((hover != null) && (mean != null)) { + const target = Math.min(Math.max(mean, r.hover_learn_min), r.hover_learn_max) + const diff = Math.abs(hover - target) + // Пояснение, почему значение не совпадёт с газом даже после обучения — в обеих ветках + const limit_note = (mean < r.hover_learn_min) + ? " (обучение ограничено снизу значением " + r.hover_learn_min + " — для очень мощного аппарата это нормально)" + : "" + let text = "MOT_THST_HOVER " + hover.toFixed(2) + ", средний газ в полёте " + mean.toFixed(2) + if (diff > r.hover_throttle_tolerance) { + const learning = params.get("MOT_HOVER_LEARN") == 2 + text += ": расхождение " + diff.toFixed(2) + limit_note + if (learning) { + text += " (обучение включено: большое расхождение значит, что висения в AltHold было мало)" + } + add("hover_throttle", "warn", "Газ висения", text, + learning ? "Повисите не меньше " + r.hover_learn_s + " с в AltHold, чтобы значение обучилось" : "MOT_HOVER_LEARN = 2 (обучение с сохранением)") + } else { + add("hover_throttle", "ok", "Газ висения", text + limit_note) + } + } + + return checks +} + +// Итог по списку проверок +function checks_summary(checks) { + const count = { ok: 0, warn: 0, fail: 0 } + for (const c of checks) { + count[c.status]++ + } + const status = (count.fail > 0) ? "fail" : (count.warn > 0) ? "warn" : "ok" + return { status, ...count } +} + +if (typeof module !== "undefined") { + module.exports = { check_rules, run_checks, checks_summary } +} diff --git a/TuneWizard/core/log.js b/TuneWizard/core/log.js new file mode 100644 index 00000000..b9737cd1 --- /dev/null +++ b/TuneWizard/core/log.js @@ -0,0 +1,228 @@ +// Сведения о логе и доступ к параметрам. Без DOM: работает в браузере и под Node (тесты). +// Лог — объект парсера JsDataflashParser: messageTypes, get(name, field), get_instance(name, instance, field). + +// Микросекунды лога → секунды. Объявлено только здесь: другие скрипты страницы используют этот глобал. +const US2S = 1 / 1000000 + +// Типы сборки из VER.BU (как в Libraries/LogHelpers.js) +const BUILD_NAMES = { 1: "Rover", 2: "Copter", 3: "Plane", 4: "AntennaTracker", 7: "Sub", 12: "Blimp" } + +function us_to_s(TimeUS) { + return Array.from(TimeUS, (t) => t * US2S) +} + +// Доступ к параметрам из сообщений PARM: последнее значение и история изменений +function log_params(log) { + const history = {} + if ("PARM" in log.messageTypes) { + const PARM = log.get("PARM") + for (let i = 0; i < PARM.Name.length; i++) { + const name = PARM.Name[i] + const value = PARM.Value[i] + const list = history[name] ??= [] + // Повторная запись того же значения — не изменение + if ((list.length == 0) || (list[list.length - 1].value != value)) { + list.push({ time: PARM.TimeUS[i] * US2S, value }) + } + } + } + return { + has: (name) => name in history, + get: (name) => (name in history) ? history[name][history[name].length - 1].value : null, + history: (name) => history[name] ?? [], + changed: (name) => (history[name]?.length ?? 0) > 1, + } +} + +// Какие сообщения есть в логе +function log_messages(log) { + const has = (name) => name in log.messageTypes + return { + pid: has("PIDR") && has("PIDP") && has("PIDY"), + rate: has("RATE"), + gyro_raw: has("GYR"), + gyro_batch: has("ISBH") && has("ISBD"), + esc: has("ESC"), + rpm: has("RPM"), + vibe: has("VIBE"), + ctun: has("CTUN"), + att: has("ATT"), + pos: has("POS"), + ftn: has("FTN1"), + } +} + +// Ряд «газ» 0–1 для окна полёта: CTUN.ThO (выход газа), иначе RATE.AOut +function throttle_series(log) { + if ("CTUN" in log.messageTypes) { + const ThO = log.get("CTUN", "ThO") + if (ThO != null) { + return { time: us_to_s(log.get("CTUN", "TimeUS")), throttle: Array.from(ThO), source: "CTUN.ThO" } + } + } + if ("RATE" in log.messageTypes) { + const AOut = log.get("RATE", "AOut") + if (AOut != null) { + return { time: us_to_s(log.get("RATE", "TimeUS")), throttle: Array.from(AOut), source: "RATE.AOut" } + } + } + return null +} + +// Окно полёта: от первого до последнего ненулевого газа, с отступом margin секунд внутрь, +// чтобы отрезать набор и сброс газа (как в FilterReview) +function throttle_window(time, throttle, margin = 1) { + let first = -1 + let last = -1 + for (let i = 0; i < throttle.length; i++) { + if (throttle[i] > 0) { + if (first < 0) { + first = i + } + last = i + } + } + if (first < 0) { + return null + } + const start = Math.ceil(time[first]) + margin + const end = Math.floor(time[last]) - margin + if (end <= start) { + return null + } + return { start, end } +} + +// Средний ненулевой газ внутри окна +function mean_throttle(time, throttle, window) { + let sum = 0 + let count = 0 + for (let i = 0; i < throttle.length; i++) { + if ((time[i] < window.start) || (time[i] > window.end) || !(throttle[i] > 0)) { + continue + } + sum += throttle[i] + count++ + } + return (count > 0) ? sum / count : null +} + +// Процентиль по ближайшему рангу +function percentile(values, fraction) { + if (values.length == 0) { + return null + } + const sorted = Float64Array.from(values).sort() + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] +} + +// Максимум массива циклом: разворачивать длинные ряды в аргументы Math.max нельзя (переполнение стека) +function max_of(values) { + let max = -Infinity + for (let i = 0; i < values.length; i++) { + if (values[i] > max) { + max = values[i] + } + } + return max +} + +// Сводка VIBE по экземплярам IMU: p95 и максимум по осям, число событий clipping. +// VIBE пишется по экземплярам, и парсер отдаёт такие сообщения только через get_instance. +// Clip в логе — счётчик нарастающим итогом, поэтому берём максимум. +function vibe_summary(log) { + if (!("VIBE" in log.messageTypes)) { + return null + } + // Старый лог без экземпляров читается как один IMU через get_instance(name, null, field) + const instances = log.messageTypes.VIBE.instances + const keys = instances ? Object.keys(instances) : [null] + const imus = [] + for (const key of keys) { + const x = log.get_instance("VIBE", key, "VibeX") + if ((x == null) || (x.length == 0)) { + continue + } + const y = log.get_instance("VIBE", key, "VibeY") + const z = log.get_instance("VIBE", key, "VibeZ") + // Clip — счётчик нарастающим итогом (с 4.1); в старых логах три счётчика Clip0..Clip2 + const clip = log.get_instance("VIBE", key, "Clip") + let clip_count = 0 + if (clip != null) { + clip_count = max_of(clip) + } else { + for (const name of ["Clip0", "Clip1", "Clip2"]) { + const old = log.get_instance("VIBE", key, name) + if (old != null) { + clip_count += max_of(old) + } + } + } + const p95 = { x: percentile(x, 0.95), y: percentile(y, 0.95), z: percentile(z, 0.95) } + imus.push({ + imu: (key == null) ? 0 : Number(key), + p95, + level: Math.max(p95.x, p95.y, p95.z), + max: Math.max(max_of(x), max_of(y), max_of(z)), + clip: clip_count, + }) + } + if (imus.length == 0) { + return null + } + return { + imus, + level: Math.max(...imus.map((s) => s.level)), + max: Math.max(...imus.map((s) => s.max)), + clip: imus.reduce((sum, s) => sum + s.clip, 0), + } +} + +// Сводная информация о логе. version — результат get_version_and_board(log) из Libraries/LogHelpers.js +function log_info(log, version = {}) { + const messages = log_messages(log) + + // Границы и длительность по первому подходящему сообщению с TimeUS (без экземпляров: такие читаются через get). + // time_start/time_end — абсолютные секунды лога, по ним страница задаёт границы графиков + let time_start = null + let time_end = null + let duration_s = null + for (const name of ["ATT", "RATE", "CTUN"]) { + if (name in log.messageTypes) { + // Сообщение может быть объявлено без единой строки: поля нет, get возвращает undefined + const t = log.get(name, "TimeUS") + if ((t != null) && (t.length > 1)) { + time_start = t[0] * US2S + time_end = t[t.length - 1] * US2S + duration_s = (t[t.length - 1] - t[0]) * US2S + break + } + } + } + + const throttle = throttle_series(log) + const window = throttle ? throttle_window(throttle.time, throttle.throttle) : null + + return { + vehicle: BUILD_NAMES[version.build_type] ?? null, + build_type: version.build_type ?? null, + fw_string: version.fw_string ?? null, + board: version.flight_controller ?? null, + filter_version: version.filter_version ?? null, + time_start, + time_end, + duration_s, + messages, + throttle_source: throttle?.source ?? null, + flight: window ? { + start: window.start, + end: window.end, + duration_s: window.end - window.start, + mean_throttle: mean_throttle(throttle.time, throttle.throttle, window), + } : null, + } +} + +if (typeof module !== "undefined") { + module.exports = { US2S, us_to_s, log_params, log_messages, throttle_series, throttle_window, mean_throttle, percentile, vibe_summary, log_info } +} diff --git a/TuneWizard/index.html b/TuneWizard/index.html new file mode 100644 index 00000000..141b20ad --- /dev/null +++ b/TuneWizard/index.html @@ -0,0 +1,226 @@ + + + + + +Мастер настройки + + + + + + + + + + + + + + + + +
+ +
+ Тема + + + +
+ +
+ ArduPilot · Copter +

Мастер настройки

+

Один лог — шесть шагов: проверка лога, спектр гироскопов, гармонический notch, низкочастотные фильтры, PID по осям и сводка с файлом параметров. На каждом шаге мастер предлагает значения, вы их правите и подтверждаете.

+
+ + + +
+
00 · Лог и проверки

Загрузите лог полёта

+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+

Интервал анализа задаётся ползунком под графиком полёта или полями выше. По умолчанию — участок с ненулевым газом с отступом по секунде с каждой стороны. Окно FFT — степень двойки.

+
+ + + + + +
Полёт

Крен, тангаж, газ и высота

+
+
+ + + + + + + +
+ + + + diff --git a/TuneWizard/wizard.js b/TuneWizard/wizard.js new file mode 100644 index 00000000..a4450a1d --- /dev/null +++ b/TuneWizard/wizard.js @@ -0,0 +1,507 @@ +// Мастер настройки: состояние, навигация по шагам, темы и графики. Математика — в core/. + +// Парсер логов — ES-модуль, подгружается асинхронно; load() ждёт import_done +var DataflashParser +const import_done = import('../modules/JsDataflashParser/parser.js').then((mod) => { DataflashParser = mod.default }) + +// Названия шагов для полосы навигации +const STEP_TITLES = ["Лог и проверки", "Спектр до фильтров", "Гармонический notch", "Низкочастотные фильтры", "PID по осям", "Сводка"] + +// Состояние мастера. Лог и производные — только здесь; шаги читают и пишут steps[n] +const state = { + file_name: null, + log: null, + version: null, + params: null, + info: null, + vibe: null, + checks: null, + settings: { start: 0, end: 0, min: null, max: null, window_size: 1024 }, + step: 0, + steps: [], +} + +// ---------- Тема ---------- + +function css_token(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() +} + +function apply_theme(name) { + const root = document.documentElement + if (name === "auto") { + root.removeAttribute("data-theme") + } else { + root.setAttribute("data-theme", name) + } + for (const button of document.querySelectorAll(".themebar button[data-t]")) { + button.setAttribute("aria-pressed", String(button.dataset.t === name)) + } + restyle_plots() +} + +function init_theme() { + let saved = null + try { + saved = localStorage.getItem("theme_v1") + } catch { + // Хранилище недоступно — работаем без памяти темы + } + apply_theme(saved ?? "auto") + for (const button of document.querySelectorAll(".themebar button[data-t]")) { + button.addEventListener("click", () => { + apply_theme(button.dataset.t) + try { + localStorage.setItem("theme_v1", button.dataset.t) + } catch { + // см. выше + } + }) + } + // Смена системной темы при выборе «как в системе» + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { + if (!document.documentElement.hasAttribute("data-theme")) { + restyle_plots() + } + }) +} + +// Цвета линий графиков из токенов: главная, вторая, хорошо, плохо +function plot_colors() { + return [css_token("--accent"), css_token("--low"), css_token("--ok"), css_token("--high")] +} + +// Общая часть layout Plotly под текущую тему +function plot_layout_theme() { + const font = { family: '"IBM Plex Mono", ui-monospace, monospace', size: 11, color: css_token("--ink-2") } + return { + paper_bgcolor: css_token("--surface"), + plot_bgcolor: css_token("--surface"), + font, + axis: { + gridcolor: css_token("--line-soft"), + linecolor: css_token("--line"), + zerolinecolor: css_token("--line"), + tickfont: font, + title: { font }, + }, + // Ползунок диапазона под осью X: фон и рамка тоже из токенов, иначе в тёмной теме он остаётся светлым + rangeslider: { bgcolor: css_token("--surface-2"), bordercolor: css_token("--line") }, + } +} + +// ---------- График полёта ---------- + +const flight_data = { data: [], layout: {} } + +function setup_flight_plot() { + const names = ["Крен", "Тангаж", "Газ", "Высота"] + const units = ["°", "°", "", "м"] + const colors = plot_colors() + const theme = plot_layout_theme() + + flight_data.data = [] + for (let i = 0; i < names.length; i++) { + flight_data.data[i] = { + mode: "lines", + name: names[i], + meta: names[i], + line: { color: colors[i], width: 1.2 }, + yaxis: (i == 0) ? "y" : "y" + (i + 1), + hovertemplate: "%{meta}
%{x:.2f} с
%{y:.2f} " + units[i], + x: [], + y: [], + } + } + + flight_data.layout = { + paper_bgcolor: theme.paper_bgcolor, + plot_bgcolor: theme.plot_bgcolor, + font: theme.font, + xaxis: { ...theme.axis, title: { text: "Время, с", font: theme.font }, domain: [0.07, 0.93], zeroline: false, showline: true, mirror: true, rangeslider: theme.rangeslider }, + showlegend: false, + margin: { b: 50, l: 50, r: 50, t: 20 }, + } + const positions = [0, 0.06, 0.94, 1] + for (let i = 0; i < names.length; i++) { + const key = (i == 0) ? "yaxis" : "yaxis" + (i + 1) + flight_data.layout[key] = { + ...theme.axis, + title: { text: names[i], font: { ...theme.font, color: colors[i] } }, + tickfont: { ...theme.font, color: colors[i] }, + zeroline: false, + showline: true, + mirror: true, + side: (i < 2) ? "left" : "right", + position: positions[i], + color: colors[i], + } + if (i > 0) { + flight_data.layout[key].overlaying = "y" + } + } + + const plot = document.getElementById("FlightData") + Plotly.purge(plot) + Plotly.newPlot(plot, flight_data.data, flight_data.layout, { displaylogo: false, responsive: true }) + + // Ползунок и зум по X задают интервал анализа + plot.on("plotly_relayout", (data) => { + if (data["xaxis.range"] !== undefined) { + set_interval(data["xaxis.range"][0], data["xaxis.range"][1]) + } else if ((data["xaxis.range[0]"] !== undefined) && (data["xaxis.range[1]"] !== undefined)) { + set_interval(data["xaxis.range[0]"], data["xaxis.range[1]"]) + } else if (data["xaxis.autorange"] === true && state.info != null) { + set_interval(state.settings.min, state.settings.max) + } + }) +} + +// Перерисовать графики под новую тему, данные сохраняются +function restyle_plots() { + if (typeof Plotly === "undefined" || document.getElementById("FlightData") == null || flight_data.data.length == 0) { + return + } + const colors = plot_colors() + const theme = plot_layout_theme() + flight_data.layout.paper_bgcolor = theme.paper_bgcolor + flight_data.layout.plot_bgcolor = theme.plot_bgcolor + flight_data.layout.font = theme.font + // range оси X при этом сохраняется: Object.assign только дописывает поля темы + Object.assign(flight_data.layout.xaxis, theme.axis, { title: { text: "Время, с", font: theme.font }, rangeslider: theme.rangeslider }) + for (let i = 0; i < flight_data.data.length; i++) { + flight_data.data[i].line.color = colors[i] + const key = (i == 0) ? "yaxis" : "yaxis" + (i + 1) + Object.assign(flight_data.layout[key], theme.axis, { + title: { text: flight_data.data[i].name, font: { ...theme.font, color: colors[i] } }, + tickfont: { ...theme.font, color: colors[i] }, + color: colors[i], + }) + } + Plotly.react("FlightData", flight_data.data, flight_data.layout, { displaylogo: false, responsive: true }) +} + +// ---------- Интервал анализа и окно FFT ---------- + +function set_interval(start, end) { + start = Math.floor(start) + end = Math.ceil(end) + if (state.settings.min != null) { + start = Math.max(start, state.settings.min) + end = Math.min(end, state.settings.max) + } + state.settings.start = start + state.settings.end = end + document.getElementById("TimeStart").value = start + document.getElementById("TimeEnd").value = end +} + +function interval_input_changed() { + let start = Math.floor(parseFloat(document.getElementById("TimeStart").value)) + let end = Math.ceil(parseFloat(document.getElementById("TimeEnd").value)) + // Сначала зажать в границы лога, потом проверять порядок: иначе конец за пределами лога + // срезался бы уже в set_interval после проверки, и интервал мог бы инвертироваться + if (state.settings.min != null) { + start = Math.max(start, state.settings.min) + end = Math.min(end, state.settings.max) + } + if (!(end > start)) { + set_interval(state.settings.start, state.settings.end) + return + } + set_interval(start, end) + flight_data.layout.xaxis.range = [state.settings.start, state.settings.end] + flight_data.layout.xaxis.autorange = false + Plotly.relayout("FlightData", { "xaxis.range": [state.settings.start, state.settings.end] }) +} + +// Окно FFT — ближайшая степень двойки в сторону изменения +function window_size_changed(input) { + const value = parseInt(input.value) + let size = state.settings.window_size + if (value > size) { + size *= 2 + } else if (value < size) { + size = Math.max(64, size / 2) + } + state.settings.window_size = size + input.value = size +} + +// ---------- Шаги ---------- + +function step_available(n) { + if (n == 0) { + return true + } + return (state.info != null) && (state.info.vehicle === "Copter") +} + +function set_step(n) { + if (!step_available(n)) { + return + } + state.step = n + for (const section of document.querySelectorAll("section[data-step]")) { + section.hidden = Number(section.dataset.step) !== n + } + update_wizbar() + if (n == 0) { + restyle_plots() + } +} + +function update_wizbar() { + const buttons = document.querySelectorAll("#wizbar button") + for (let i = 0; i < buttons.length; i++) { + buttons[i].disabled = !step_available(i) + if (i === state.step) { + buttons[i].setAttribute("aria-current", "step") + } else { + buttons[i].removeAttribute("aria-current") + } + } +} + +function init_wizard() { + const bar = document.getElementById("wizbar") + for (let i = 0; i < STEP_TITLES.length; i++) { + const button = document.createElement("button") + button.type = "button" + button.innerHTML = 'Шаг ' + i + '' + STEP_TITLES[i] + '' + button.addEventListener("click", () => set_step(i)) + bar.appendChild(button) + } + setup_flight_plot() + set_step(0) +} + +// ---------- Загрузка лога (шаг 0) ---------- + +function read_file(input) { + const file = input.files[0] + if (file == null) { + return + } + state.file_name = file.name + const reader = new FileReader() + reader.onload = function () { + loading_call(async () => { + try { + await load(reader.result) + } catch (e) { + // Ошибка парсера или данных: показать в вердикте, а не оставлять оверлей + console.error(e) + load_failed("Не удалось прочитать лог «" + state.file_name + "»: " + e.message) + } + }) + } + reader.onerror = function () { + load_failed("Не удалось прочитать файл «" + state.file_name + "»: " + (reader.error ? reader.error.message : "ошибка чтения")) + } + reader.readAsArrayBuffer(file) +} + +function show_verdict(status, text) { + const verdict = document.getElementById("log_verdict") + verdict.className = "verdict verdict--" + status + verdict.textContent = text + verdict.hidden = false +} + +// Единый путь ошибки загрузки: сбросить полусостояние, назвать причину и очистить поле выбора, +// чтобы повторный выбор того же файла снова вызвал change +function load_failed(message) { + reset() + show_verdict("fail", message) + document.getElementById("fileItem").value = "" +} + +function reset() { + document.title = "Мастер настройки" + state.log = null + state.version = null + state.params = null + state.info = null + state.vibe = null + state.checks = null + state.settings = { start: 0, end: 0, min: null, max: null, window_size: state.settings.window_size } + state.steps = [] + document.getElementById("fileLabel").textContent = "Выбрать лог…" + for (const id of ["TimeStart", "TimeEnd", "FFTWindow_size"]) { + document.getElementById(id).disabled = true + } + for (const id of ["TimeStart", "TimeEnd"]) { + const input = document.getElementById(id) + input.value = 0 + input.removeAttribute("min") + input.removeAttribute("max") + } + document.getElementById("log_verdict").hidden = true + document.getElementById("log_info").hidden = true + document.getElementById("log_checks").hidden = true + for (const trace of flight_data.data) { + trace.x = [] + trace.y = [] + } + // Пустой график перерисовать сразу, чтобы прошлый полёт не оставался на экране; диапазон — снова авто + if (flight_data.layout.xaxis) { + delete flight_data.layout.xaxis.range + flight_data.layout.xaxis.autorange = true + } + restyle_plots() + update_wizbar() +} + +async function load(buffer) { + await import_done + const start = performance.now() + reset() + + const log = new DataflashParser() + log.processData(buffer, []) + if (!("PARM" in log.messageTypes)) { + load_failed("В файле «" + state.file_name + "» нет параметров (PARM): это не лог ArduPilot или он повреждён") + return + } + + state.log = log + state.version = get_version_and_board(log) + state.params = log_params(log) + state.info = log_info(log, state.version) + state.vibe = vibe_summary(log) + state.checks = run_checks(state.info, state.params, state.vibe) + + document.title = "Мастер настройки: " + state.file_name + document.getElementById("fileLabel").textContent = state.file_name + + render_info() + render_checks() + plot_flight() + + for (const id of ["TimeStart", "TimeEnd", "FFTWindow_size"]) { + document.getElementById(id).disabled = false + } + update_wizbar() + console.log("Загрузка заняла " + Math.round(performance.now() - start) + " мс") +} + +// Экранирование текста для innerHTML: строки из лога (прошивка, плата) могут содержать < и & +function html_escape(text) { + return String(text).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])) +} + +function stat_tile(key, value, accent) { + return '
' + html_escape(key) + '' + html_escape(value) + '
' +} + +function format_seconds(s) { + if (s == null) { + return "—" + } + // Сначала округлить всё, потом делить: иначе 59.6 с давало бы «0 мин 60 с» + const t = Math.round(s) + const m = Math.floor(t / 60) + const sec = t % 60 + return (m > 0) ? m + " мин " + sec + " с" : sec + " с" +} + +function render_info() { + const info = state.info + const params = state.params + const flight = info.flight + const html = [ + stat_tile("Аппарат", info.vehicle ?? "—", info.vehicle === "Copter"), + stat_tile("Прошивка", info.fw_string ?? "—"), + stat_tile("Плата", info.board ?? "—"), + stat_tile("Длительность лога", format_seconds(info.duration_s)), + stat_tile("Полёт с газом", flight ? format_seconds(flight.duration_s) : "нет"), + stat_tile("Средний газ", (flight && flight.mean_throttle != null) ? flight.mean_throttle.toFixed(2) : "—"), + stat_tile("INS_GYRO_FILTER", params.get("INS_GYRO_FILTER") ?? "—"), + stat_tile("SCHED_LOOP_RATE", params.get("SCHED_LOOP_RATE") ?? "—"), + stat_tile("Версия фильтра", info.filter_version ?? "—"), + ] + const box = document.getElementById("log_info") + box.innerHTML = html.join("") + box.hidden = false +} + +function render_checks() { + const labels = { ok: "OK", warn: "ВНИМ", fail: "СТОП" } + const box = document.getElementById("log_checks") + box.innerHTML = state.checks.map((c) => + '
' + labels[c.status] + '' + + '

' + html_escape(c.title) + '

' + html_escape(c.text) + '

' + + (c.fix ? '

Что сделать: ' + html_escape(c.fix) + '

' : "") + '
' + ).join("") + box.hidden = false + + const summary = checks_summary(state.checks) + const texts = { + ok: "Лог пригоден: все проверки пройдены", + warn: "Лог пригоден с оговорками: " + summary.warn + " предупреждений", + fail: "Есть блокирующие проблемы: " + summary.fail + " — см. список ниже", + } + show_verdict(summary.status, texts[summary.status]) +} + +function plot_flight() { + const log = state.log + // log.get возвращает undefined для сообщения без строк или без поля: каждый ряд рисуем только целиком + if ("ATT" in log.messageTypes) { + const TimeUS = log.get("ATT", "TimeUS") + const Roll = log.get("ATT", "Roll") + const Pitch = log.get("ATT", "Pitch") + if ((TimeUS != null) && (Roll != null) && (Pitch != null)) { + const time = us_to_s(TimeUS) + flight_data.data[0].x = time + flight_data.data[0].y = Roll + flight_data.data[1].x = time + flight_data.data[1].y = Pitch + } + } + const throttle = throttle_series(log) + if ((throttle != null) && (throttle.time != null) && (throttle.throttle != null)) { + flight_data.data[2].x = throttle.time + flight_data.data[2].y = throttle.throttle + } + if ("POS" in log.messageTypes) { + const TimeUS = log.get("POS", "TimeUS") + const RelHomeAlt = log.get("POS", "RelHomeAlt") + if ((TimeUS != null) && (RelHomeAlt != null)) { + flight_data.data[3].x = us_to_s(TimeUS) + flight_data.data[3].y = RelHomeAlt + } + } + + // Границы данных: абсолютные секунды лога из log_info, иначе по нарисованным рядам + let min = state.info.time_start + let max = state.info.time_end + if ((min == null) || (max == null)) { + min = Infinity + max = -Infinity + for (const trace of flight_data.data) { + if (trace.x.length > 0) { + min = Math.min(min, trace.x[0]) + max = Math.max(max, trace.x[trace.x.length - 1]) + } + } + if (!Number.isFinite(min)) { + min = 0 + max = state.info.duration_s ?? 0 + } + } + state.settings.min = Math.floor(min) + state.settings.max = Math.ceil(max) + // Интервал по умолчанию: полёт с газом, иначе весь лог + const flight = state.info.flight + set_interval(flight ? flight.start : min, flight ? flight.end : max) + for (const id of ["TimeStart", "TimeEnd"]) { + document.getElementById(id).min = state.settings.min + document.getElementById(id).max = state.settings.max + } + + flight_data.layout.xaxis.range = [state.settings.start, state.settings.end] + flight_data.layout.xaxis.autorange = false + Plotly.react("FlightData", flight_data.data, flight_data.layout, { displaylogo: false, responsive: true }) +} diff --git a/docs/plans/2026-10-23-tune-wizard-design.md b/docs/plans/2026-10-23-tune-wizard-design.md new file mode 100644 index 00000000..f263a77a --- /dev/null +++ b/docs/plans/2026-10-23-tune-wizard-design.md @@ -0,0 +1,140 @@ +# Мастер настройки (TuneWizard) — дизайн + +Дата: 23.10.2026. Статус: утверждён в обсуждении, реализация не начата. + +## 1. Цель и границы + +Личный инструмент в форке `deathnemo/WebTools`, объединяющий FilterReview и PIDReview в **пошаговый мастер настройки Copter**: загрузил один `.bin` → мастер ведёт по шагам, на каждом считает и **предлагает** значения параметров, пользователь правит и подтверждает → на выходе сводка и файл `.param`. + +Решения, принятые 23.10.2026: + +- Только Copter в первой версии (оси Roll/Pitch/Yaw, сообщения `PIDR/P/Y`, запасной вариант `RATE`). +- Полуавтомат: инструмент предлагает значения, пользователь подтверждает. Полностью автоматического отчёта нет. +- Один лог за проход + память о прошлом проходе в `localStorage`. +- Инструмент не предназначен для PR в upstream: интерфейс на русском, оформление по общим правилам проектов (две темы, Archivo / IBM Plex). Графики — Plotly, оформленный под токены темы (исключение из правила «встроенный SVG», принято осознанно ради зума, ховера и спектрограмм). +- Архитектура «A»: новая папка `TuneWizard/` с DOM-независимым ядром, математика **переносится** из FilterReview/PIDReview; оригинальные страницы не меняются, `git pull upstream` сливается без конфликтов. Дублирование ~1500 строк математики принято сознательно. +- Ветка `tune-wizard`, `main` отслеживает `upstream/main`. + +Отвергнутые варианты: обёртка над страницами через iframe (нет доступа к данным для предложений, чужой стиль и язык); переделка существующих страниц на месте (конфликты имён `load/reset/calculate/redraw/run_batch_fft`, потеря чистых обновлений из upstream). + +## 2. Структура + +``` +TuneWizard/ + index.html страница мастера: шаги, формы, контейнеры графиков, стили и переключатель темы + wizard.js навигация по шагам, объект state, рисование Plotly, формы «предложено → принято» + core/ чистая математика без document, экспорт через typeof module (как AirspeedFit/airspeedfit_core.js) + log.js open_log(buffer) → парсер, тип аппарата, версия, плата, доступ к параметрам и истории их изменений + gyro.js load_gyro(log, params): batch (ISBH/ISBD) или raw (GYR), pre/post, частоты, первичный гироскоп; + gyro_fft(gyro, {window}) → спектры по окнам; mean_spectrum(fft, [t0, t1]) + filters.js Biquad / Notch / MultiNotch / HarmonicNotch (версия фильтра — аргумент); + transfer(filters, fft, [t0, t1]) → H по окнам; estimate_post(fft, H); bode(H); phase_lag(bode, f) + tracking.js Static / Throttle / RPM / ESC / FFT / Logged; alert() заменён на возвращаемую {error} + pid.js load_pid(log, params): PIDR/P/Y или RATE, наборы параметров по времени, батчи; + pid_fft(axis, window); step_response(axis, fft, [t0, t1]) + metrics.js пики спектра и превышение над шумом; шум D-терма; метрики отклика + autofit.js автоподбор первого notch (ESC → газ по моторам → средний газ), перенос PR ArduPilot/WebTools#363 + checks.js проверки шага 0 → [{id, status: ok|warn|fail, text, fix}] + params.js реестр предложений {name, current, proposed, accepted, source}; текст .param + Readme.md + logs/ локальные логи для ручной проверки, в .gitignore +images/TuneWizard_Icon.png +index.html ссылка на лендинге +tests/tunewizard.test.cjs +docs/plans/ этот документ и планы реализации +``` + +Переиспользуем без копирования: `modules/JsDataflashParser/parser.js`, Plotly, fft.js, tippy, `Libraries/` (Array_Math, fft, Param_Helpers, LogHelpers, DecodeDevID, FileSaver, LoadingOverlay). + +Откуда переносим в `core/`: + +| Модуль | Источник | +|---|---| +| `gyro.js` | `FilterReview/FilterReview.js`: `load_from_batch` (2051), `load_from_raw_log` (2232), `run_batch_fft` (277) | +| `filters.js` | `FilterReview/FilterReview.js`: строки 12–275, `calculate_transfer_function` (893), `get_phase` (1058), оценка post в `redraw_post_estimate_and_bode` (1289) | +| `tracking.js` | `FilterReview/tracking/*.js` (1116 строк); `get_filter_version()` и `Gyro_batch` заменяются аргументами | +| `pid.js` | `PIDReview/PIDReview.js`: `split_into_batches` (1378), `load` (1437), `redraw_step` (1031) | +| `autofit.js` | PR #363 `FilterReview/AutoFit.js`; `Gyro_batch`, `tracking_methods`, `find_start_index` → аргументы | + +**Состояние.** Один объект `state` в `wizard.js`: распарсенный лог, параметры, общие настройки (интервал анализа, размер окна FFT), `steps[n]` с предложенными и принятыми значениями. В `localStorage` (ключ `tunewizard_v1`) — только принятые параметры последнего прохода, дата, имя лога. + +**Оформление.** Токены двух тем, шрифты и элементы (`.lab`, `.stats`, `.verdict`, `.themebar`, печать) по общим правилам проектов. Plotly берёт цвета из токенов через `getComputedStyle` и перерисовывается при смене темы. + +## 3. Шаги мастера + +Полоса из шести шагов; переход вперёд только после того, как шаг принят или пропущен с пометкой. Интервал анализа и размер окна FFT общие для всех шагов. + +**0 · Лог и проверки.** Загрузка `.bin`. Карточка: аппарат (не Copter → стоп), версия/плата, длительность, время с газом, `INS_GYRO_FILTER`, `SCHED_LOOP_RATE`. Проверки `ok / warn / fail` с подсказкой «как исправить»: + +- бит PID в `LOG_BITMASK`: есть `PIDR/P/Y` → ok, только `RATE` → warn; +- данные гироскопа raw/batch: нет → шаги 1–3 недоступны; +- `INS_LOG_BAT_OPT`: 4 (pre + post) → ok, только post → warn (оценку «станет» не построить); +- источник трекинга: ESC-телеметрия / RPM / только газ; +- висение ≥ 30 с; +- VIBE < 30 ok, 30–60 warn, > 60 fail; clipping = 0; +- `MOT_THST_HOVER` против среднего газа висения. + +Ниже — память прошлого прохода: «предлагалось → в логе сейчас», применено или нет. График полёта (roll / pitch / газ / высота) с ползунком интервала; автоподбор интервала как в FilterReview (газ > 0 с отступом 1 с). + +**1 · Спектр до фильтров.** Средний спектр X/Y/Z первичного гироскопа и спектрограмма. Таблица пиков: частота, уровень, превышение над шумом, источник («моторы, гармоника N», если кратно частоте ESC/газа, иначе «рама/резонанс»). Вердикт: главный шум; есть ли шум выше `INS_GYRO_FILTER`. Предложений нет. + +**2 · Гармонический notch.** Автоподбор предзаполняет `INS_HNTCH_ENABLE/MODE/REF/FREQ/BW/ATT/HMNCS/FM_RAT/OPTS` рядом с текущими значениями из лога. Графики: pre / оценка post / логированный post. Метрики: остаточный пик, ослабление, фазовая задержка на 10/20/50 Гц. Некратный остаточный пик ≥ 6 дБ → необязательное предложение статического `INS_HNTC2`. Кнопка «Принять». + +**3 · Низкочастотные фильтры.** Выбор размера пропеллера → `INS_GYRO_FILTER`; `FLTD = FLTT = INS_GYRO_FILTER/2`; `ATC_RAT_YAW_FLTE = 2`; `ATC_ACC_*_MAX` по размеру. Бодэ суммарной задержки notch + LPF с предупреждением. «Принять». + +**4 · PID по осям** (вкладки Roll / Pitch / Yaw). Текущие `ATC_RAT_*` по наборам (если менялись в полёте). Графики: Tar/Act во времени, спектр P/I/D/Out, step response (все окна + среднее). Метрики: нарастание 10–90 %, перерегулирование, установление ±5 %, остаточные колебания, шум D. Предложения по правилам раздела 5. Работает и на `RATE` без компонент. + +**5 · Сводка.** Таблица «параметр · было · станет · шаг», вердикты, «что перелететь» (например, `INS_LOG_BAT_OPT = 2`, висение 30 с, повторить проход). Кнопки «Скачать .param», «Запомнить проход», печать. + +## 4. Поток данных + +файл → `log.js` → параллельно `gyro.js` и `pid.js` → шаги читают эти объекты, считают через `filters/tracking/metrics/autofit/checks` и пишут результаты в `state.steps[n]` → `params.js` собирает сводку и файл. + +## 5. Правила предложений + +Все числа — в одном блоке `rules` в начале модуля с комментарием-источником. Обозначения: **[wiki]** — ardupilot.org, **[код]** — исходники ArduPilot, **[PR363]** — метод автоподбора из PR ArduPilot/WebTools#363, **[наше]** — эвристика, которой у ArduPilot нет; такие пороги настраиваемые и в интерфейсе подписаны как ориентир. + +**Шаг 0.** VIBE < 30 / 30–60 / > 60, clipping = 0 **[wiki, Measuring Vibration — сверить при реализации]**; висение ≥ 30 с без ввода **[wiki, Initial tuning flight]**; расхождение `MOT_THST_HOVER` и среднего газа висения > 0.05 → warn, совет `MOT_HOVER_LEARN = 2` **[wiki + порог наше]**. + +**Шаг 2 (notch).** Гармоника notch'ится при пике ≥ 6 дБ над локальным шумом; `ATT` = выступ сильнейшего пика, 10–50 дБ; ESC: `MODE 3`, `REF 1`, `FREQ` = 80 % от 5-го процентиля моторной частоты; газ: `MODE 1`, `REF` = средний газ, `FREQ` подгоняется по пикам, `FM_RAT` понижается ради трекинга ниже REF; `OPTS` — multi-source, один notch на гармонику **[PR363]**. `BW = FREQ/2`, при multi-source `FREQ/4` **[wiki]**. В режиме ESC `FREQ` выше `INS_GYRO_FILTER` **[wiki]**. Некратный остаточный пик ≥ 6 дБ → статический `INS_HNTC2`, необязательно **[наше]**. + +**Шаг 3 (LPF).** `INS_GYRO_FILTER` 80 / 40 / 20 Гц для 5″ / 10″ / 20″+; `INS_ACCEL_FILTER 10`; `ATC_RAT_RLL/PIT_FLTD = FLTT = INS_GYRO_FILTER/2`; `ATC_RAT_YAW_FLTT = INS_GYRO_FILTER/2`; `ATC_RAT_YAW_FLTE = 2`; `ATC_ACC_P/R_MAX` 1100 / 500 / 200 и `ATC_ACC_Y_MAX` 200 / 100 / 90 для 10″ / 20″ / 30″ **[wiki, Setting up for tuning]**. Порог предупреждения о суммарной фазовой задержке **[наше]**. + +**Шаг 4 (PID).** Метрики по нормированному отклику (цель = 1). Правила: остаточные колебания на высокой частоте → `D −10 %`, на низкой → `P −10 %`; перерегулирование > 20 % → `P −10 %`; медленно и без перерегулирования → `P +10 %`; при каждом изменении P → `I = P` **[wiki, Manual tuning: шаги 10 % и I = P; пороги метрик — наше]**. Заметный шум D выше 50 Гц → предупреждение «горячие моторы», совет снизить D или `FLTD` **[наше]**. Пределы AutoTune: `D ≤ 0.2`, `P` 0.01–2.0, yaw `I = 0.1·P` **[код, `AC_AutoTune_Multi.cpp`]**. За один проход — не более одного изменения на коэффициент. + +## 6. Ошибки + +Ядро не вызывает `alert()`: возвращает `{error}` или бросает типизированную ошибку; `wizard.js` показывает её в строке-вердикте шага с действием. Не `.bin` или нет `PARM` → шаг 0 fail. Частичные данные деградируют, не ломают: нет гироскопа → шаги 1–3 закрыты с пояснением; нет бита PID → шаг 4 на `RATE`; нет ESC → трекинг по газу. Расчёт в главном потоке с `LoadingOverlay`; ядро без DOM, перенос в Web Worker возможен позже. `localStorage` в `try/catch`. + +## 7. Тесты + +- `npm run lint`; файлы `core/` добавить в `DUAL_SCRIPTS` в `eslint.config.mjs` (единственная правка общего файла). +- `tests/tunewizard.test.cjs` на синтетике: ослабление notch на центральной частоте; пик FFT синусоиды; отклик модельной системы с известными метриками; автоподбор на синтетическом спектре; проверки шага 0 на поддельных параметрах; текст `.param`. +- Регрессия переноса: спектры и step response мастера совпадают с FilterReview / PIDReview на одном и том же реальном логе (`TuneWizard/logs/`, в `.gitignore`). +- Нужен Node ≥ 22 (`winget install OpenJS.NodeJS.LTS`). + +## 8. Этапы + +1. Каркас страницы, темы, шаг 0 (лог, проверки, график полёта). +2. Перенос gyro / filters / tracking, шаги 1–2 с автоподбором. +3. Шаг 3 (LPF, бодэ). +4. Перенос pid / step response, метрики, шаг 4. +5. Сводка, `.param`, память прохода, печать. +6. Readme, иконка, ссылка на лендинге. + +Каждый этап закрывается линтом, тестами и ручной проверкой на реальном логе; коммит на этап, push в `origin`. + +## 9. Открытые вопросы + +- Пороги VIBE / clipping сверить со страницей wiki «Measuring Vibration». +- Нужны 1–2 реальных `.bin` с batch/raw IMU и битом PID для регрессии. +- Пороги, помеченные **[наше]**, уточнить по результатам на реальных логах. + +## 10. Источники + +- README и ESLint-конфиг WebTools: https://github.com/ArduPilot/WebTools +- PR #363 «FilterReview: add auto fit for the first notch», PR #362 (до четырёх notch), PR #328 (FFT фильтров цели/ошибки PID) +- Notch: https://ardupilot.org/copter/docs/common-imu-notch-filtering.html, …/common-throttle-based-notch.html, …/common-esc-telem-based-notch.html, …/common-imu-fft.html +- Логирование: https://ardupilot.org/copter/docs/common-imu-batchsampling.html, …/common-raw-imu-logging.html +- Настройка: https://ardupilot.org/copter/docs/tuning-process-instructions.html, …/setting-up-for-tuning.html, …/initial-tuning-flight.html, …/ac_rollpitchtuning.html, …/evaluating-the-aircraft-tune.html +- Исходники ArduPilot (локально `C:\Users\taa03\Desktop\Repo\ardupilot`): `libraries/AC_AutoTune/AC_AutoTune_Multi.cpp`, `libraries/AC_AttitudeControl/AC_AttitudeControl.h`, `libraries/Filter`, `libraries/AP_InertialSensor` diff --git a/docs/plans/2026-10-23-tune-wizard-stage1.md b/docs/plans/2026-10-23-tune-wizard-stage1.md new file mode 100644 index 00000000..434a5b1e --- /dev/null +++ b/docs/plans/2026-10-23-tune-wizard-stage1.md @@ -0,0 +1,1517 @@ +# Мастер настройки (TuneWizard) — этап 1: каркас, темы, шаг 0 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Цель:** страница `TuneWizard/` с переключателем тем и полосой шагов, на которой шаг 0 загружает `.bin`, показывает карточку лога, список проверок пригодности и график полёта с выбором интервала анализа. + +**Архитектура:** по дизайну `docs/plans/2026-10-23-tune-wizard-design.md`. Математика и проверки — в `TuneWizard/core/*.js` (классические скрипты без `document`, экспорт через `typeof module`, тестируются под Node). Страница `index.html` + `wizard.js` — состояние, навигация, Plotly. Оригинальные FilterReview/PIDReview не трогаем. + +**Стек:** браузерный JS без сборки, Plotly из `modules/plotly.js`, парсер `modules/JsDataflashParser/parser.js` (ES-модуль, `export default DataflashParser`), общие `Libraries/` (LogHelpers, Param_Helpers, LoadingOverlay, Plotly_helpers), тесты `node --test` (Node 24, `npm test`), ESLint (`npm run lint`). + +**Соглашения:** +- Всё на русском: тексты страницы, комментарии, сообщения коммитов. Имена параметров, сообщений лога, `id`/`class` — латиницей. +- Коммит после каждой задачи; сообщение в стиле `TuneWizard: …`, в конце строка `Co-Authored-By: Claude Fable 5.1 `. +- В новом терминале до перезапуска VS Code Node не в PATH: `$env:Path = "C:\Program Files\nodejs;" + $env:Path` (PowerShell) или `export PATH="/c/Program Files/nodejs:$PATH"` (Git Bash). +- Ветка `tune-wizard` уже создана и отслеживает `origin/tune-wizard`. + +**Факты о данных, на которые опирается код:** +- Парсер: `log.processData(arrayBuffer, [])`, `log.messageTypes` (объект имя → описание; у сообщений с экземплярами есть `.instances`), `log.get(name)` → объект массивов всех полей, `log.get(name, field)` → массив поля, `log.get_instance(name, inst, field)`. +- `get_version_and_board(log)` (`Libraries/LogHelpers.js`) → `{ flight_controller, board_id, fw_string, fw_hash, os_string, build_type, filter_version }`; `build_type` 2 = Copter. +- Поля: `VIBE`: `TimeUS, IMU, VibeX, VibeY, VibeZ, Clip` (Clip — счётчик нарастающим итогом); `CTUN`: `ThO` — выход газа 0–1, `ThH` — расчётный газ висения; `RATE`: `AOut` — выход газа; `ATT`: `Roll, Pitch`; `POS`: `RelHomeAlt`. +- `LOG_BITMASK`: бит PID = 12, IMU_RAW = 19 (`ArduCopter/defines.h`). `INS_LOG_BAT_OPT`: бит 1 — post-filter, бит 2 — pre+post. `INS_RAW_LOG_OPT`: бит 2 — post-filter, бит 3 — pre+post (см. `FilterReview.js:2243`). +- Классические скрипты одной страницы делят глобальную лексическую область: **нельзя объявлять одно и то же `const`/`let` в двух файлах** (будет `SyntaxError`). `US2S` объявляется только в `core/log.js`. + +--- + +### Задача 1: ядро `core/log.js` — сведения о логе, параметры, газ, VIBE + +**Файлы:** +- Создать: `TuneWizard/core/log.js` +- Создать: `tests/tunewizard.test.cjs` +- Изменить: `eslint.config.mjs:56` (список `DUAL_SCRIPTS`) + +**Шаг 1: добавить файлы ядра в `DUAL_SCRIPTS`** + +В `eslint.config.mjs` строку + +```js +const DUAL_SCRIPTS = ['AirspeedFit/airspeedfit_core.js', 'SimpleGCS/commands.js'] +``` + +заменить на + +```js +const DUAL_SCRIPTS = [ + 'AirspeedFit/airspeedfit_core.js', + 'SimpleGCS/commands.js', + // Ядро мастера настройки: те же скрипты гоняются под Node в tests/tunewizard.test.cjs + 'TuneWizard/core/log.js', + 'TuneWizard/core/checks.js' +] +``` + +**Шаг 2: написать падающие тесты** + +Создать `tests/tunewizard.test.cjs`: + +```js +// Тесты ядра мастера настройки (TuneWizard/core). Логи — поддельные объекты с интерфейсом парсера. +const test = require('node:test') +const assert = require('node:assert/strict') + +const core_log = require('../TuneWizard/core/log.js') + +// Поддельный лог: messages = { ИМЯ: { поле: массив } }, params = [[имя, значение, TimeUS], ...] +function fake_log({ messages = {}, params = [] } = {}) { + const types = { ...messages } + if (params.length > 0) { + types.PARM = { + TimeUS: params.map((p) => p[2] ?? 0), + Name: params.map((p) => p[0]), + Value: params.map((p) => p[1]) + } + } + return { + messageTypes: Object.fromEntries(Object.keys(types).map((name) => [name, {}])), + get(name, field) { + const msg = types[name] + if (msg == null) return undefined + return field ? msg[field] : msg + }, + get_instance(name, instance, field) { + return this.get(name, field) + } + } +} + +const copter_version = { build_type: 2, fw_string: 'ArduCopter V4.6.0', flight_controller: 'CubeOrange', filter_version: 2 } + +test('log_params: последнее значение, история изменений, отсутствующий параметр', () => { + const log = fake_log({ params: [ + ['ATC_RAT_RLL_P', 0.1, 1e6], + ['ATC_RAT_RLL_P', 0.1, 2e6], // повтор без изменения не попадает в историю + ['ATC_RAT_RLL_P', 0.15, 30e6], + ['INS_GYRO_FILTER', 40, 1e6] + ] }) + const params = core_log.log_params(log) + assert.equal(params.get('ATC_RAT_RLL_P'), 0.15) + assert.equal(params.get('INS_GYRO_FILTER'), 40) + assert.equal(params.get('NOPE'), null) + assert.equal(params.has('NOPE'), false) + assert.deepEqual(params.history('ATC_RAT_RLL_P'), [{ time: 1, value: 0.1 }, { time: 30, value: 0.15 }]) + assert.equal(params.changed('ATC_RAT_RLL_P'), true) + assert.equal(params.changed('INS_GYRO_FILTER'), false) +}) + +test('log_messages: флаги наличия сообщений', () => { + const log = fake_log({ messages: { PIDR: {}, PIDP: {}, PIDY: {}, GYR: {}, ESC: {}, ATT: {} } }) + const m = core_log.log_messages(log) + assert.equal(m.pid, true) + assert.equal(m.rate, false) + assert.equal(m.gyro_raw, true) + assert.equal(m.gyro_batch, false) + assert.equal(m.esc, true) + assert.equal(m.rpm, false) + assert.equal(m.vibe, false) +}) + +test('throttle_window: от первого до последнего ненулевого газа с отступом 1 с', () => { + const time = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + const throttle = [0, 0, 0.3, 0.4, 0.4, 0.4, 0.4, 0.4, 0.3, 0, 0] + assert.deepEqual(core_log.throttle_window(time, throttle), { start: 3, end: 7 }) + assert.equal(core_log.throttle_window(time, new Array(11).fill(0)), null) + // Слишком короткий полёт: окно схлопывается + assert.equal(core_log.throttle_window([0, 1, 2], [0, 0.5, 0]), null) +}) + +test('mean_throttle: средний ненулевой газ внутри окна', () => { + const time = [0, 1, 2, 3, 4, 5] + const throttle = [0, 0.2, 0.4, 0.6, 0, 0.9] + // Сумма чисел с плавающей точкой: сравниваем с допуском + assert.ok(Math.abs(core_log.mean_throttle(time, throttle, { start: 1, end: 4 }) - 0.4) < 1e-9) + assert.equal(core_log.mean_throttle(time, [0, 0, 0, 0, 0, 0], { start: 1, end: 4 }), null) +}) + +test('throttle_series: CTUN.ThO предпочтительнее RATE.AOut', () => { + const both = fake_log({ messages: { + CTUN: { TimeUS: [0, 1e6], ThO: [0.3, 0.4] }, + RATE: { TimeUS: [0, 1e6], AOut: [0.5, 0.6] } + } }) + assert.deepEqual(core_log.throttle_series(both), { time: [0, 1], throttle: [0.3, 0.4], source: 'CTUN.ThO' }) + const rate_only = fake_log({ messages: { RATE: { TimeUS: [0, 2e6], AOut: [0.5, 0.6] } } }) + assert.deepEqual(core_log.throttle_series(rate_only), { time: [0, 2], throttle: [0.5, 0.6], source: 'RATE.AOut' }) + assert.equal(core_log.throttle_series(fake_log()), null) +}) + +test('percentile: 95-й процентиль', () => { + const values = Array.from({ length: 100 }, (_, i) => i + 1) + assert.equal(core_log.percentile(values, 0.95), 95) + assert.equal(core_log.percentile([], 0.95), null) +}) + +test('vibe_summary: по экземплярам IMU — p95 и максимум по осям, clipping', () => { + const log = fake_log({ messages: { VIBE: { + TimeUS: [0, 1e6, 2e6, 3e6], + IMU: [0, 1, 0, 1], + VibeX: [10, 20, 12, 22], + VibeY: [5, 6, 7, 8], + VibeZ: [30, 40, 35, 45], + Clip: [0, 0, 3, 1] // нарастающий итог: IMU0 дошёл до 3, IMU1 до 1 + } } }) + const vibe = core_log.vibe_summary(log) + assert.equal(vibe.imus.length, 2) + assert.equal(vibe.imus[0].imu, 0) + assert.equal(vibe.imus[0].max, 35) + assert.equal(vibe.imus[1].max, 45) + assert.equal(vibe.imus[0].clip, 3) + assert.equal(vibe.imus[1].clip, 1) + assert.equal(vibe.max, 45) + assert.equal(vibe.clip, 4) + assert.ok(vibe.level >= 35 && vibe.level <= 45) + assert.equal(core_log.vibe_summary(fake_log()), null) +}) + +test('log_info: аппарат, длительность, окно полёта, средний газ', () => { + const n = 61 + const TimeUS = Array.from({ length: n }, (_, i) => i * 1e6) + const ThO = TimeUS.map((_, i) => (i >= 5 && i <= 55) ? 0.35 : 0) + const log = fake_log({ messages: { + ATT: { TimeUS, Roll: new Array(n).fill(0), Pitch: new Array(n).fill(0) }, + CTUN: { TimeUS, ThO }, + GYR: {}, PIDR: {}, PIDP: {}, PIDY: {} + } }) + const info = core_log.log_info(log, copter_version) + assert.equal(info.vehicle, 'Copter') + assert.equal(info.fw_string, 'ArduCopter V4.6.0') + assert.equal(info.board, 'CubeOrange') + assert.equal(info.filter_version, 2) + assert.equal(info.duration_s, 60) + assert.equal(info.throttle_source, 'CTUN.ThO') + assert.equal(info.flight.start, 6) + assert.equal(info.flight.end, 54) + assert.equal(info.flight.duration_s, 48) + assert.ok(Math.abs(info.flight.mean_throttle - 0.35) < 1e-9) + assert.equal(info.messages.pid, true) +}) + +test('log_info: без полёта и без ATT', () => { + const log = fake_log({ messages: { RATE: { TimeUS: [0, 1e6, 2e6], AOut: [0, 0, 0] } } }) + const info = core_log.log_info(log, { build_type: 3 }) + assert.equal(info.vehicle, 'Plane') + assert.equal(info.flight, null) + assert.equal(info.duration_s, 2) +}) +``` + +**Шаг 3: убедиться, что тесты падают** + +Запуск: `node --test tests/tunewizard.test.cjs` +Ожидание: FAIL — `Cannot find module '../TuneWizard/core/log.js'`. + +**Шаг 4: реализовать `TuneWizard/core/log.js`** + +```js +// Сведения о логе и доступ к параметрам. Без DOM: работает в браузере и под Node (тесты). +// Лог — объект парсера JsDataflashParser: messageTypes, get(name, field), get_instance(name, instance, field). + +// Микросекунды лога → секунды. Объявлено только здесь: другие скрипты страницы используют этот глобал. +const US2S = 1 / 1000000 + +// Типы сборки из VER.BU (как в Libraries/LogHelpers.js) +const BUILD_NAMES = { 1: "Rover", 2: "Copter", 3: "Plane", 4: "AntennaTracker", 7: "Sub", 12: "Blimp" } + +function us_to_s(TimeUS) { + return Array.from(TimeUS, (t) => t * US2S) +} + +// Доступ к параметрам из сообщений PARM: последнее значение и история изменений +function log_params(log) { + const history = {} + if ("PARM" in log.messageTypes) { + const PARM = log.get("PARM") + for (let i = 0; i < PARM.Name.length; i++) { + const name = PARM.Name[i] + const value = PARM.Value[i] + const list = history[name] ??= [] + // Повторная запись того же значения — не изменение + if ((list.length == 0) || (list[list.length - 1].value != value)) { + list.push({ time: PARM.TimeUS[i] * US2S, value }) + } + } + } + return { + has: (name) => name in history, + get: (name) => (name in history) ? history[name][history[name].length - 1].value : null, + history: (name) => history[name] ?? [], + changed: (name) => (history[name]?.length ?? 0) > 1, + } +} + +// Какие сообщения есть в логе +function log_messages(log) { + const has = (name) => name in log.messageTypes + return { + pid: has("PIDR") && has("PIDP") && has("PIDY"), + rate: has("RATE"), + gyro_raw: has("GYR"), + gyro_batch: has("ISBH") && has("ISBD"), + esc: has("ESC"), + rpm: has("RPM"), + vibe: has("VIBE"), + ctun: has("CTUN"), + att: has("ATT"), + pos: has("POS"), + ftn: has("FTN1"), + } +} + +// Ряд «газ» 0–1 для окна полёта: CTUN.ThO (выход газа), иначе RATE.AOut +function throttle_series(log) { + if ("CTUN" in log.messageTypes) { + return { time: us_to_s(log.get("CTUN", "TimeUS")), throttle: Array.from(log.get("CTUN", "ThO")), source: "CTUN.ThO" } + } + if ("RATE" in log.messageTypes) { + return { time: us_to_s(log.get("RATE", "TimeUS")), throttle: Array.from(log.get("RATE", "AOut")), source: "RATE.AOut" } + } + return null +} + +// Окно полёта: от первого до последнего ненулевого газа, с отступом margin секунд внутрь, +// чтобы отрезать набор и сброс газа (как в FilterReview) +function throttle_window(time, throttle, margin = 1) { + let first = -1 + let last = -1 + for (let i = 0; i < throttle.length; i++) { + if (throttle[i] > 0) { + if (first < 0) { + first = i + } + last = i + } + } + if (first < 0) { + return null + } + const start = Math.ceil(time[first]) + margin + const end = Math.floor(time[last]) - margin + if (end <= start) { + return null + } + return { start, end } +} + +// Средний ненулевой газ внутри окна +function mean_throttle(time, throttle, window) { + let sum = 0 + let count = 0 + for (let i = 0; i < throttle.length; i++) { + if ((time[i] < window.start) || (time[i] > window.end) || !(throttle[i] > 0)) { + continue + } + sum += throttle[i] + count++ + } + return (count > 0) ? sum / count : null +} + +// Процентиль по ближайшему рангу +function percentile(values, fraction) { + if (values.length == 0) { + return null + } + const sorted = Array.from(values).sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] +} + +// Сводка VIBE по экземплярам IMU: p95 и максимум по осям, число событий clipping. +// Clip в логе — счётчик нарастающим итогом, поэтому берём максимум. +function vibe_summary(log) { + if (!("VIBE" in log.messageTypes)) { + return null + } + const VIBE = log.get("VIBE") + const by_imu = {} + for (let i = 0; i < VIBE.TimeUS.length; i++) { + const s = by_imu[VIBE.IMU[i]] ??= { x: [], y: [], z: [], clip: 0 } + s.x.push(VIBE.VibeX[i]) + s.y.push(VIBE.VibeY[i]) + s.z.push(VIBE.VibeZ[i]) + s.clip = Math.max(s.clip, VIBE.Clip[i]) + } + const imus = Object.entries(by_imu).map(([imu, s]) => { + const p95 = { x: percentile(s.x, 0.95), y: percentile(s.y, 0.95), z: percentile(s.z, 0.95) } + return { + imu: Number(imu), + p95, + level: Math.max(p95.x, p95.y, p95.z), + max: Math.max(...s.x, ...s.y, ...s.z), + clip: s.clip, + } + }) + return { + imus, + level: Math.max(...imus.map((s) => s.level)), + max: Math.max(...imus.map((s) => s.max)), + clip: imus.reduce((sum, s) => sum + s.clip, 0), + } +} + +// Сводная информация о логе. version — результат get_version_and_board(log) из Libraries/LogHelpers.js +function log_info(log, version) { + const messages = log_messages(log) + + // Длительность по первому подходящему сообщению с TimeUS + let duration_s = null + for (const name of ["ATT", "RATE", "CTUN", "IMU"]) { + if (name in log.messageTypes) { + const t = log.get(name, "TimeUS") + if (t.length > 1) { + duration_s = (t[t.length - 1] - t[0]) * US2S + break + } + } + } + + const throttle = throttle_series(log) + const window = throttle ? throttle_window(throttle.time, throttle.throttle) : null + + return { + vehicle: BUILD_NAMES[version.build_type] ?? null, + build_type: version.build_type ?? null, + fw_string: version.fw_string ?? null, + board: version.flight_controller ?? null, + filter_version: version.filter_version ?? null, + duration_s, + messages, + throttle_source: throttle?.source ?? null, + flight: window ? { + start: window.start, + end: window.end, + duration_s: window.end - window.start, + mean_throttle: mean_throttle(throttle.time, throttle.throttle, window), + } : null, + } +} + +if (typeof module !== "undefined") { + module.exports = { US2S, us_to_s, log_params, log_messages, throttle_series, throttle_window, mean_throttle, percentile, vibe_summary, log_info } +} +``` + +**Шаг 5: убедиться, что тесты проходят** + +Запуск: `node --test tests/tunewizard.test.cjs` +Ожидание: `ℹ pass 9`, `ℹ fail 0`. + +Запуск: `npm run lint` +Ожидание: без ошибок (код выхода 0). + +**Шаг 6: коммит** + +```bash +git add eslint.config.mjs TuneWizard/core/log.js tests/tunewizard.test.cjs +git commit -F - <<'EOF' +TuneWizard: ядро log.js — сведения о логе, параметры, газ, VIBE + +Чистые функции без DOM с тестами под Node: история параметров, +флаги сообщений, окно полёта по газу, сводка VIBE, log_info. + +Co-Authored-By: Claude Fable 5.1 +EOF +``` + +--- + +### Задача 2: ядро `core/checks.js` — проверки пригодности лога + +**Файлы:** +- Создать: `TuneWizard/core/checks.js` +- Изменить: `tests/tunewizard.test.cjs` (дописать тесты) + +**Шаг 1: дописать падающие тесты** + +В конец `tests/tunewizard.test.cjs` добавить: + +```js +const checks = require('../TuneWizard/core/checks.js') + +// Сведения о хорошем логе Copter; тесты переопределяют нужные поля +function good_info(over = {}) { + return { + vehicle: 'Copter', fw_string: 'ArduCopter V4.6.0', board: 'CubeOrange', filter_version: 2, duration_s: 120, + messages: { pid: true, rate: true, gyro_raw: true, gyro_batch: false, esc: true, rpm: false, vibe: true, ctun: true, att: true, pos: true, ftn: false }, + throttle_source: 'CTUN.ThO', + flight: { start: 5, end: 100, duration_s: 95, mean_throttle: 0.33 }, + ...over + } +} +function fake_params(values) { + return { get: (name) => (name in values) ? values[name] : null, has: (name) => name in values } +} +const good_params = fake_params({ MOT_THST_HOVER: 0.35, MOT_HOVER_LEARN: 2, INS_RAW_LOG_OPT: 8, INS_GYRO_FILTER: 40 }) +const good_vibe = { imus: [{ imu: 0, level: 12, max: 20, clip: 0 }], level: 12, max: 20, clip: 0 } + +function by_id(list, id) { + const found = list.find((c) => c.id === id) + assert.ok(found, `нет проверки ${id}`) + return found +} + +test('run_checks: хороший лог — все проверки ok', () => { + const list = checks.run_checks(good_info(), good_params, good_vibe) + for (const c of list) assert.equal(c.status, 'ok', `${c.id}: ${c.text}`) + assert.equal(checks.checks_summary(list).status, 'ok') +}) + +test('run_checks: не Copter — fail, итог fail', () => { + const list = checks.run_checks(good_info({ vehicle: 'Plane' }), good_params, good_vibe) + assert.equal(by_id(list, 'vehicle').status, 'fail') + assert.equal(checks.checks_summary(list).status, 'fail') +}) + +test('run_checks: PID — ok / warn (только RATE) / fail', () => { + const only_rate = good_info({ messages: { ...good_info().messages, pid: false, rate: true } }) + assert.equal(by_id(checks.run_checks(only_rate, good_params, good_vibe), 'pid_log').status, 'warn') + const none = good_info({ messages: { ...good_info().messages, pid: false, rate: false } }) + assert.equal(by_id(checks.run_checks(none, good_params, good_vibe), 'pid_log').status, 'fail') +}) + +test('run_checks: нет данных гироскопа — fail с подсказкой', () => { + const info = good_info({ messages: { ...good_info().messages, gyro_raw: false, gyro_batch: false } }) + const c = by_id(checks.run_checks(info, good_params, good_vibe), 'gyro_log') + assert.equal(c.status, 'fail') + assert.match(c.fix, /INS_LOG_BAT_MASK/) +}) + +test('run_checks: raw pre/post по INS_RAW_LOG_OPT', () => { + const post_only = fake_params({ INS_RAW_LOG_OPT: 4 }) + assert.equal(by_id(checks.run_checks(good_info(), post_only, good_vibe), 'pre_post').status, 'warn') + const pre_only = fake_params({ INS_RAW_LOG_OPT: 0 }) + assert.equal(by_id(checks.run_checks(good_info(), pre_only, good_vibe), 'pre_post').status, 'ok') +}) + +test('run_checks: batch pre/post по INS_LOG_BAT_OPT', () => { + const info = good_info({ messages: { ...good_info().messages, gyro_raw: false, gyro_batch: true } }) + assert.equal(by_id(checks.run_checks(info, fake_params({ INS_LOG_BAT_OPT: 4 }), good_vibe), 'pre_post').status, 'ok') + assert.equal(by_id(checks.run_checks(info, fake_params({ INS_LOG_BAT_OPT: 2 }), good_vibe), 'pre_post').status, 'warn') +}) + +test('run_checks: источник трекинга — ESC ok, RPM ok, только газ warn', () => { + const rpm = good_info({ messages: { ...good_info().messages, esc: false, rpm: true } }) + assert.equal(by_id(checks.run_checks(rpm, good_params, good_vibe), 'tracking').status, 'ok') + const throttle = good_info({ messages: { ...good_info().messages, esc: false, rpm: false } }) + assert.equal(by_id(checks.run_checks(throttle, good_params, good_vibe), 'tracking').status, 'warn') +}) + +test('run_checks: полёт — нет газа fail, короткий warn', () => { + assert.equal(by_id(checks.run_checks(good_info({ flight: null }), good_params, good_vibe), 'flight').status, 'fail') + const short = good_info({ flight: { start: 5, end: 25, duration_s: 20, mean_throttle: 0.33 } }) + assert.equal(by_id(checks.run_checks(short, good_params, good_vibe), 'flight').status, 'warn') +}) + +test('run_checks: вибрации — пороги 30/60 и clipping', () => { + const warn = { ...good_vibe, level: 45 } + assert.equal(by_id(checks.run_checks(good_info(), good_params, warn), 'vibe').status, 'warn') + const fail = { ...good_vibe, level: 70 } + assert.equal(by_id(checks.run_checks(good_info(), good_params, fail), 'vibe').status, 'fail') + const clip = { ...good_vibe, clip: 5 } + assert.equal(by_id(checks.run_checks(good_info(), good_params, clip), 'clipping').status, 'warn') + assert.equal(by_id(checks.run_checks(good_info(), good_params, null), 'vibe').status, 'warn') +}) + +test('run_checks: газ висения против MOT_THST_HOVER', () => { + const far = fake_params({ MOT_THST_HOVER: 0.5, MOT_HOVER_LEARN: 0 }) + const c = by_id(checks.run_checks(good_info(), far, good_vibe), 'hover_throttle') + assert.equal(c.status, 'warn') + assert.match(c.fix, /MOT_HOVER_LEARN/) + const close = fake_params({ MOT_THST_HOVER: 0.31 }) + assert.equal(by_id(checks.run_checks(good_info(), close, good_vibe), 'hover_throttle').status, 'ok') +}) + +test('checks_summary: счётчики', () => { + const s = checks.checks_summary([{ status: 'ok' }, { status: 'warn' }, { status: 'warn' }]) + assert.deepEqual(s, { status: 'warn', ok: 1, warn: 2, fail: 0 }) +}) +``` + +**Шаг 2: убедиться, что тесты падают** + +Запуск: `node --test tests/tunewizard.test.cjs` +Ожидание: FAIL — `Cannot find module '../TuneWizard/core/checks.js'`. + +**Шаг 3: реализовать `TuneWizard/core/checks.js`** + +```js +// Проверки пригодности лога (шаг 0). Чистые функции без DOM. +// Источники порогов: [wiki] — ardupilot.org, [код] — исходники ArduPilot, [наше] — эвристика, у ArduPilot числа нет. + +const check_rules = { + vibe_warn: 30, // м/с²: выше — возможны проблемы [wiki, Measuring Vibration] + vibe_fail: 60, // м/с²: выше — почти наверняка проблемы [wiki, Measuring Vibration] + hover_min_s: 30, // с: минимальное висение для усреднения спектра [wiki, Initial tuning flight] + hover_throttle_tolerance: 0.05, // допустимое расхождение MOT_THST_HOVER и среднего газа [наше] + log_bitmask_pid: 12, // номер бита PID в LOG_BITMASK [код, ArduCopter/defines.h MASK_LOG_PID] + log_bitmask_imu_raw: 19, // номер бита IMU_RAW [код, MASK_LOG_IMU_RAW] +} + +// info — из log_info, params — из log_params, vibe — из vibe_summary (может быть null). +// Возвращает список { id, status: "ok" | "warn" | "fail", title, text, fix }. +function run_checks(info, params, vibe) { + const checks = [] + const add = (id, status, title, text, fix) => checks.push({ id, status, title, text, fix: fix ?? null }) + const m = info.messages + const r = check_rules + + // Аппарат + if (info.vehicle === "Copter") { + add("vehicle", "ok", "Аппарат", "Copter, " + (info.fw_string ?? "версия неизвестна") + (info.board ? ", " + info.board : "")) + } else { + add("vehicle", "fail", "Аппарат", "Лог от " + (info.vehicle ?? "неизвестного аппарата") + "; мастер поддерживает только Copter", "Загрузите лог ArduCopter") + } + + // Сообщения PID + const pid_fix = "Включите бит PID (" + r.log_bitmask_pid + ") в LOG_BITMASK и перелетите" + if (m.pid) { + add("pid_log", "ok", "Сообщения PID", "PIDR/PIDP/PIDY есть: доступны компоненты P/I/D/FF, отфильтрованные цель и ошибка") + } else if (m.rate) { + add("pid_log", "warn", "Сообщения PID", "Есть только RATE: на шаге 4 не будет компонент P/I/D", pid_fix) + } else { + add("pid_log", "fail", "Сообщения PID", "Нет ни PID, ни RATE: шаг 4 недоступен", pid_fix) + } + + // Данные гироскопа + if (m.gyro_raw || m.gyro_batch) { + add("gyro_log", "ok", "Данные гироскопа", "Есть " + (m.gyro_raw ? "raw (GYR)" : "batch (ISBH/ISBD)") + ": шаги 1–3 доступны") + } else { + add("gyro_log", "fail", "Данные гироскопа", "Нет ни raw (GYR), ни batch (ISBH/ISBD): шаги 1–3 недоступны", + "Batch: INS_LOG_BAT_MASK = 1, INS_LOG_BAT_OPT = 4. Raw (платы H7): бит IMU_RAW (" + r.log_bitmask_imu_raw + ") в LOG_BITMASK, INS_RAW_LOG_OPT = 8") + } + + // До/после фильтров + if (m.gyro_raw) { + const opt = params.get("INS_RAW_LOG_OPT") ?? 0 + if (opt & (1 << 3)) { + add("pre_post", "ok", "До/после фильтров", "Raw-лог до и после фильтров: оценку «станет» можно сверить с реальными данными") + } else if (opt & (1 << 2)) { + add("pre_post", "warn", "До/после фильтров", "Raw-лог только после фильтров: спектра до фильтров и оценки «станет» не будет", "INS_RAW_LOG_OPT = 8 (до и после)") + } else { + add("pre_post", "ok", "До/после фильтров", "Raw-лог только до фильтров: оценка «станет» строится расчётом, реальных данных после фильтров нет") + } + } else if (m.gyro_batch) { + const opt = params.get("INS_LOG_BAT_OPT") ?? 0 + if (opt & (1 << 2)) { + add("pre_post", "ok", "До/после фильтров", "Batch-лог до и после фильтров: оценку «станет» можно сверить с реальными данными") + } else if (opt & (1 << 1)) { + add("pre_post", "warn", "До/после фильтров", "Batch-лог только после фильтров: спектра до фильтров и оценки «станет» не будет", "INS_LOG_BAT_OPT = 4 (до и после)") + } else { + add("pre_post", "ok", "До/после фильтров", "Batch-лог только до фильтров: оценка «станет» строится расчётом, реальных данных после фильтров нет") + } + } + + // Источник трекинга notch + if (m.esc) { + add("tracking", "ok", "Источник трекинга notch", "ESC-телеметрия (ESC): лучший источник, сам подстраивается под нагрузку") + } else if (m.rpm) { + add("tracking", "ok", "Источник трекинга notch", "Датчик оборотов (RPM)") + } else { + add("tracking", "warn", "Источник трекинга notch", "Нет ESC-телеметрии и RPM: доступен только трекинг по газу (INS_HNTCH_MODE = 1)", + "Если регуляторы умеют телеметрию или двунаправленный DShot — включите, это точнее") + } + + // Полёт + if (info.flight == null) { + add("flight", "fail", "Полёт", "Газ не поднимался выше нуля: полёта в логе нет") + } else if (info.flight.duration_s < r.hover_min_s) { + add("flight", "warn", "Полёт", "Полёт с газом " + Math.round(info.flight.duration_s) + " с: мало для усреднения спектра", "Висение не меньше " + r.hover_min_s + " с без ввода") + } else { + add("flight", "ok", "Полёт", "Полёт с газом " + Math.round(info.flight.duration_s) + " с, средний газ " + (info.flight.mean_throttle ?? 0).toFixed(2)) + } + + // Вибрации + if (vibe == null) { + add("vibe", "warn", "Вибрации", "Сообщений VIBE нет: уровень вибраций не проверить") + } else { + const level = Math.round(vibe.level) + const text = "Уровень (95-й процентиль) " + level + " м/с², максимум " + Math.round(vibe.max) + " м/с²" + if (vibe.level >= r.vibe_fail) { + add("vibe", "fail", "Вибрации", text + ": выше " + r.vibe_fail + " — почти наверняка проблемы", "Проверьте крепление автопилота, балансировку пропеллеров и моторов") + } else if (vibe.level >= r.vibe_warn) { + add("vibe", "warn", "Вибрации", text + ": выше " + r.vibe_warn + " — возможны проблемы", "Проверьте крепление автопилота и балансировку пропеллеров") + } else { + add("vibe", "ok", "Вибрации", text) + } + if (vibe.clip > 0) { + add("clipping", "warn", "Clipping", vibe.clip + " событий clipping акселерометра", "Проверьте крепление автопилота и балансировку пропеллеров") + } else { + add("clipping", "ok", "Clipping", "Событий clipping нет") + } + } + + // Газ висения против MOT_THST_HOVER + const hover = params.get("MOT_THST_HOVER") + const mean = info.flight?.mean_throttle ?? null + if ((hover != null) && (mean != null)) { + const diff = Math.abs(hover - mean) + const text = "MOT_THST_HOVER " + hover.toFixed(2) + ", средний газ в полёте " + mean.toFixed(2) + if (diff > r.hover_throttle_tolerance) { + const learning = params.get("MOT_HOVER_LEARN") == 2 + add("hover_throttle", "warn", "Газ висения", text + ": расхождение " + diff.toFixed(2) + (learning ? " (обучение включено, значение сохранится после полёта)" : ""), + learning ? "Повисите не меньше 30 с в AltHold, чтобы значение обучилось" : "MOT_HOVER_LEARN = 2 (обучение с сохранением)") + } else { + add("hover_throttle", "ok", "Газ висения", text) + } + } + + return checks +} + +// Итог по списку проверок +function checks_summary(checks) { + const count = { ok: 0, warn: 0, fail: 0 } + for (const c of checks) { + count[c.status]++ + } + const status = (count.fail > 0) ? "fail" : (count.warn > 0) ? "warn" : "ok" + return { status, ...count } +} + +if (typeof module !== "undefined") { + module.exports = { check_rules, run_checks, checks_summary } +} +``` + +**Шаг 4: убедиться, что тесты проходят** + +Запуск: `node --test tests/tunewizard.test.cjs` +Ожидание: `ℹ pass 20`, `ℹ fail 0`. + +Запуск: `npm run lint` → код выхода 0. + +**Шаг 5: коммит** + +```bash +git add TuneWizard/core/checks.js tests/tunewizard.test.cjs +git commit -F - <<'EOF' +TuneWizard: ядро checks.js — проверки пригодности лога + +Аппарат, бит PID, данные гироскопа, pre/post, источник трекинга, +длительность полёта, VIBE и clipping, газ висения. Пороги в одном +блоке check_rules с указанием источника. + +Co-Authored-By: Claude Fable 5.1 +EOF +``` + +--- + +### Задача 3: страница `index.html` и `wizard.js` — темы, полоса шагов, навигация + +Проверка этой задачи ручная (страница без данных), поэтому шаги — «написать → открыть в браузере → линт → коммит». + +**Файлы:** +- Создать: `TuneWizard/index.html` +- Создать: `TuneWizard/wizard.js` + +**Шаг 1: создать `TuneWizard/index.html`** + +```html + + + + + +Мастер настройки + + + + + + + + + + + + + + + + + +
+ +
+ Тема + + + +
+ +
+ ArduPilot · Copter +

Мастер настройки

+

Один лог — шесть шагов: проверка лога, спектр гироскопов, гармонический notch, низкочастотные фильтры, PID по осям и сводка с файлом параметров. На каждом шаге мастер предлагает значения, вы их правите и подтверждаете.

+
+ + + +
+
00 · Лог и проверки

Загрузите лог полёта

+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+

Интервал анализа задаётся ползунком под графиком полёта или полями выше. По умолчанию — участок с ненулевым газом с отступом по секунде с каждой стороны. Окно FFT — степень двойки.

+
+ + + + + +
Полёт

Крен, тангаж, газ и высота

+
+
+ + + + + + + +
+ + + + +``` + +**Шаг 2: создать `TuneWizard/wizard.js`** + +```js +// Мастер настройки: состояние, навигация по шагам, темы и графики. Математика — в core/. + +// Парсер логов — ES-модуль, подгружается асинхронно; load() ждёт import_done +var DataflashParser +const import_done = import('../modules/JsDataflashParser/parser.js').then((mod) => { DataflashParser = mod.default }) + +// Названия шагов для полосы навигации +const STEP_TITLES = ["Лог и проверки", "Спектр до фильтров", "Гармонический notch", "Низкочастотные фильтры", "PID по осям", "Сводка"] + +// Состояние мастера. Лог и производные — только здесь; шаги читают и пишут steps[n] +const state = { + file_name: null, + log: null, + version: null, + params: null, + info: null, + vibe: null, + checks: null, + settings: { start: 0, end: 0, window_size: 1024 }, + step: 0, + steps: [], +} + +// ---------- Тема ---------- + +function token(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() +} + +function apply_theme(name) { + const root = document.documentElement + if (name === "auto") { + root.removeAttribute("data-theme") + } else { + root.setAttribute("data-theme", name) + } + for (const button of document.querySelectorAll(".themebar button[data-t]")) { + button.setAttribute("aria-pressed", String(button.dataset.t === name)) + } + restyle_plots() +} + +function init_theme() { + let saved = null + try { + saved = localStorage.getItem("theme_v1") + } catch (e) { + // Хранилище недоступно — работаем без памяти темы + } + apply_theme(saved ?? "auto") + for (const button of document.querySelectorAll(".themebar button[data-t]")) { + button.addEventListener("click", () => { + apply_theme(button.dataset.t) + try { + localStorage.setItem("theme_v1", button.dataset.t) + } catch (e) { + // см. выше + } + }) + } + // Смена системной темы при выборе «как в системе» + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { + if (!document.documentElement.hasAttribute("data-theme")) { + restyle_plots() + } + }) +} + +// Цвета линий графиков из токенов: главная, вторая, хорошо, плохо +function plot_colors() { + return [token("--accent"), token("--low"), token("--ok"), token("--high")] +} + +// Общая часть layout Plotly под текущую тему +function plot_layout_theme() { + const font = { family: '"IBM Plex Mono", ui-monospace, monospace', size: 11, color: token("--ink-2") } + return { + paper_bgcolor: token("--surface"), + plot_bgcolor: token("--surface"), + font, + axis: { + gridcolor: token("--line-soft"), + linecolor: token("--line"), + zerolinecolor: token("--line"), + tickfont: font, + title: { font }, + }, + } +} + +// ---------- График полёта ---------- + +const flight_data = { data: [], layout: {} } + +function setup_flight_plot() { + const names = ["Крен", "Тангаж", "Газ", "Высота"] + const units = ["°", "°", "", "м"] + const colors = plot_colors() + const theme = plot_layout_theme() + + flight_data.data = [] + for (let i = 0; i < names.length; i++) { + flight_data.data[i] = { + mode: "lines", + name: names[i], + meta: names[i], + line: { color: colors[i], width: 1.2 }, + yaxis: (i == 0) ? "y" : "y" + (i + 1), + hovertemplate: "%{meta}
%{x:.2f} с
%{y:.2f} " + units[i], + x: [], + y: [], + } + } + + flight_data.layout = { + paper_bgcolor: theme.paper_bgcolor, + plot_bgcolor: theme.plot_bgcolor, + font: theme.font, + xaxis: { ...theme.axis, title: { text: "Время, с", font: theme.font }, domain: [0.07, 0.93], zeroline: false, showline: true, mirror: true, rangeslider: {} }, + showlegend: false, + margin: { b: 50, l: 50, r: 50, t: 20 }, + } + const positions = [0, 0.06, 0.94, 1] + for (let i = 0; i < names.length; i++) { + const key = (i == 0) ? "yaxis" : "yaxis" + (i + 1) + flight_data.layout[key] = { + ...theme.axis, + title: { text: names[i], font: { ...theme.font, color: colors[i] } }, + tickfont: { ...theme.font, color: colors[i] }, + zeroline: false, + showline: true, + mirror: true, + side: (i < 2) ? "left" : "right", + position: positions[i], + color: colors[i], + } + if (i > 0) { + flight_data.layout[key].overlaying = "y" + } + } + + const plot = document.getElementById("FlightData") + Plotly.purge(plot) + Plotly.newPlot(plot, flight_data.data, flight_data.layout, { displaylogo: false, responsive: true }) + + // Ползунок и зум по X задают интервал анализа + plot.on("plotly_relayout", (data) => { + if (data["xaxis.range"] !== undefined) { + set_interval(data["xaxis.range"][0], data["xaxis.range"][1]) + } else if ((data["xaxis.range[0]"] !== undefined) && (data["xaxis.range[1]"] !== undefined)) { + set_interval(data["xaxis.range[0]"], data["xaxis.range[1]"]) + } else if (data["xaxis.autorange"] === true && state.info != null) { + set_interval(state.settings.min, state.settings.max) + } + }) +} + +// Перерисовать графики под новую тему, данные сохраняются +function restyle_plots() { + if (typeof Plotly === "undefined" || document.getElementById("FlightData") == null || flight_data.data.length == 0) { + return + } + const colors = plot_colors() + const theme = plot_layout_theme() + flight_data.layout.paper_bgcolor = theme.paper_bgcolor + flight_data.layout.plot_bgcolor = theme.plot_bgcolor + flight_data.layout.font = theme.font + Object.assign(flight_data.layout.xaxis, theme.axis, { title: { text: "Время, с", font: theme.font } }) + for (let i = 0; i < flight_data.data.length; i++) { + flight_data.data[i].line.color = colors[i] + const key = (i == 0) ? "yaxis" : "yaxis" + (i + 1) + Object.assign(flight_data.layout[key], theme.axis, { + title: { text: flight_data.data[i].name, font: { ...theme.font, color: colors[i] } }, + tickfont: { ...theme.font, color: colors[i] }, + color: colors[i], + }) + } + Plotly.react("FlightData", flight_data.data, flight_data.layout, { displaylogo: false, responsive: true }) +} + +// ---------- Интервал анализа и окно FFT ---------- + +function set_interval(start, end) { + start = Math.floor(start) + end = Math.ceil(end) + if (state.settings.min != null) { + start = Math.max(start, state.settings.min) + end = Math.min(end, state.settings.max) + } + state.settings.start = start + state.settings.end = end + document.getElementById("TimeStart").value = start + document.getElementById("TimeEnd").value = end +} + +function interval_input_changed() { + const start = parseFloat(document.getElementById("TimeStart").value) + const end = parseFloat(document.getElementById("TimeEnd").value) + if (!(end > start)) { + set_interval(state.settings.start, state.settings.end) + return + } + set_interval(start, end) + flight_data.layout.xaxis.range = [state.settings.start, state.settings.end] + flight_data.layout.xaxis.autorange = false + Plotly.relayout("FlightData", { "xaxis.range": [state.settings.start, state.settings.end] }) +} + +// Окно FFT — ближайшая степень двойки в сторону изменения +function window_size_changed(input) { + const value = parseInt(input.value) + let size = state.settings.window_size + if (value > size) { + size *= 2 + } else if (value < size) { + size = Math.max(64, size / 2) + } + state.settings.window_size = size + input.value = size +} + +// ---------- Шаги ---------- + +function step_available(n) { + if (n == 0) { + return true + } + return (state.info != null) && (state.info.vehicle === "Copter") +} + +function set_step(n) { + if (!step_available(n)) { + return + } + state.step = n + for (const section of document.querySelectorAll("section[data-step]")) { + section.hidden = Number(section.dataset.step) !== n + } + update_wizbar() + if (n == 0) { + restyle_plots() + } +} + +function update_wizbar() { + const buttons = document.querySelectorAll("#wizbar button") + for (let i = 0; i < buttons.length; i++) { + buttons[i].disabled = !step_available(i) + if (i === state.step) { + buttons[i].setAttribute("aria-current", "step") + } else { + buttons[i].removeAttribute("aria-current") + } + } +} + +function init_wizard() { + const bar = document.getElementById("wizbar") + for (let i = 0; i < STEP_TITLES.length; i++) { + const button = document.createElement("button") + button.type = "button" + button.innerHTML = 'Шаг ' + i + '' + STEP_TITLES[i] + '' + button.addEventListener("click", () => set_step(i)) + bar.appendChild(button) + } + setup_flight_plot() + set_step(0) +} + +// ---------- Загрузка лога (шаг 0) ---------- + +function read_file(input) { + const file = input.files[0] + if (file == null) { + return + } + const reader = new FileReader() + reader.onload = function () { + loading_call(async () => { + state.file_name = file.name + await load(reader.result) + }) + } + reader.readAsArrayBuffer(file) +} + +function show_verdict(status, text) { + const verdict = document.getElementById("log_verdict") + verdict.className = "verdict verdict--" + status + verdict.textContent = text + verdict.hidden = false +} + +function reset() { + document.title = "Мастер настройки" + state.log = null + state.version = null + state.params = null + state.info = null + state.vibe = null + state.checks = null + state.settings = { start: 0, end: 0, min: null, max: null, window_size: state.settings.window_size } + state.steps = [] + for (const id of ["TimeStart", "TimeEnd", "FFTWindow_size"]) { + document.getElementById(id).disabled = true + } + document.getElementById("log_verdict").hidden = true + document.getElementById("log_info").hidden = true + document.getElementById("log_checks").hidden = true + for (const trace of flight_data.data) { + trace.x = [] + trace.y = [] + } + update_wizbar() +} + +async function load(buffer) { + await import_done + const start = performance.now() + reset() + + const log = new DataflashParser() + log.processData(buffer, []) + if (!("PARM" in log.messageTypes)) { + show_verdict("fail", "В файле нет параметров (PARM): это не лог ArduPilot или он повреждён") + return + } + + state.log = log + state.version = get_version_and_board(log) + state.params = log_params(log) + state.info = log_info(log, state.version) + state.vibe = vibe_summary(log) + state.checks = run_checks(state.info, state.params, state.vibe) + + document.title = "Мастер настройки: " + state.file_name + document.getElementById("fileLabel").textContent = state.file_name + + render_info() + render_checks() + plot_flight() + + for (const id of ["TimeStart", "TimeEnd", "FFTWindow_size"]) { + document.getElementById(id).disabled = false + } + update_wizbar() + console.log("Загрузка заняла " + Math.round(performance.now() - start) + " мс") +} + +function stat(key, value, accent) { + return '
' + key + '' + value + '
' +} + +function format_seconds(s) { + if (s == null) { + return "—" + } + const m = Math.floor(s / 60) + const sec = Math.round(s - m * 60) + return (m > 0) ? m + " мин " + sec + " с" : sec + " с" +} + +function render_info() { + const info = state.info + const params = state.params + const flight = info.flight + const html = [ + stat("Аппарат", info.vehicle ?? "—", info.vehicle === "Copter"), + stat("Прошивка", info.fw_string ?? "—"), + stat("Плата", info.board ?? "—"), + stat("Длительность лога", format_seconds(info.duration_s)), + stat("Полёт с газом", flight ? format_seconds(flight.duration_s) : "нет"), + stat("Средний газ", (flight && flight.mean_throttle != null) ? flight.mean_throttle.toFixed(2) : "—"), + stat("INS_GYRO_FILTER", params.get("INS_GYRO_FILTER") ?? "—"), + stat("SCHED_LOOP_RATE", params.get("SCHED_LOOP_RATE") ?? "—"), + stat("Версия фильтра", info.filter_version ?? "—"), + ] + const box = document.getElementById("log_info") + box.innerHTML = html.join("") + box.hidden = false +} + +function render_checks() { + const labels = { ok: "OK", warn: "ВНИМ", fail: "СТОП" } + const box = document.getElementById("log_checks") + box.innerHTML = state.checks.map((c) => + '
' + labels[c.status] + '' + + '

' + c.title + '

' + c.text + '

' + + (c.fix ? '

Что сделать: ' + c.fix + '

' : "") + '
' + ).join("") + box.hidden = false + + const summary = checks_summary(state.checks) + const texts = { + ok: "Лог пригоден: все проверки пройдены", + warn: "Лог пригоден с оговорками: " + summary.warn + " предупреждений", + fail: "Есть блокирующие проблемы: " + summary.fail + " — см. список ниже", + } + show_verdict(summary.status, texts[summary.status]) +} + +function plot_flight() { + const log = state.log + if ("ATT" in log.messageTypes) { + const time = us_to_s(log.get("ATT", "TimeUS")) + flight_data.data[0].x = time + flight_data.data[0].y = log.get("ATT", "Roll") + flight_data.data[1].x = time + flight_data.data[1].y = log.get("ATT", "Pitch") + } + const throttle = throttle_series(log) + if (throttle != null) { + flight_data.data[2].x = throttle.time + flight_data.data[2].y = throttle.throttle + } + if ("POS" in log.messageTypes) { + flight_data.data[3].x = us_to_s(log.get("POS", "TimeUS")) + flight_data.data[3].y = log.get("POS", "RelHomeAlt") + } + + // Границы данных и интервал по умолчанию: полёт с газом, иначе весь лог + let min = Infinity + let max = -Infinity + for (const trace of flight_data.data) { + if (trace.x.length > 0) { + min = Math.min(min, trace.x[0]) + max = Math.max(max, trace.x[trace.x.length - 1]) + } + } + if (!Number.isFinite(min)) { + min = 0 + max = state.info.duration_s ?? 0 + } + state.settings.min = Math.floor(min) + state.settings.max = Math.ceil(max) + const flight = state.info.flight + set_interval(flight ? flight.start : min, flight ? flight.end : max) + for (const id of ["TimeStart", "TimeEnd"]) { + document.getElementById(id).min = state.settings.min + document.getElementById(id).max = state.settings.max + } + + flight_data.layout.xaxis.range = [state.settings.start, state.settings.end] + flight_data.layout.xaxis.autorange = false + Plotly.react("FlightData", flight_data.data, flight_data.layout, { displaylogo: false, responsive: true }) +} +``` + +**Шаг 3: проверить в браузере** + +Запуск (из корня репозитория): `python -m http.server --bind 127.0.0.1 8000` +Открыть `http://127.0.0.1:8000/TuneWizard/`. +Ожидание: заголовок «Мастер настройки», полоса из шести шагов (активен «Шаг 0», остальные недоступны), карточка с кнопкой «Выбрать лог…» и тремя отключёнными полями, пустой график полёта. Переключатель темы меняет фон страницы и графика; выбор сохраняется после перезагрузки страницы. В консоли браузера ошибок нет. + +**Шаг 4: линт** + +Запуск: `npm run lint` → код выхода 0. Если ESLint ругается на неиспользуемые глобалы страницы — это ошибка в коде, не подавлять. + +**Шаг 5: коммит** + +```bash +git add TuneWizard/index.html TuneWizard/wizard.js +git commit -F - <<'EOF' +TuneWizard: каркас страницы, темы и полоса шагов + +Страница мастера с токенами двух тем и переключателем, навигация +по шести шагам, пустой график полёта в цветах темы. + +Co-Authored-By: Claude Fable 5.1 +EOF +``` + +--- + +### Задача 4: шаг 0 на реальном логе — ручная проверка и правки + +Код шага 0 уже в `wizard.js` (задача 3). Здесь — проверка на реальном `.bin` из `TuneWizard/logs/` и исправление того, что всплывёт. + +**Файлы:** +- Возможные правки: `TuneWizard/wizard.js`, `TuneWizard/core/log.js`, `TuneWizard/core/checks.js`, `tests/tunewizard.test.cjs` + +**Шаг 1: загрузить лог** + +Открыть `http://127.0.0.1:8000/TuneWizard/`, нажать «Выбрать лог…», выбрать файл из `TuneWizard/logs/`. +Ожидание: во время загрузки — оверлей «Loading»; затем вердикт (ok/warn/fail), плитки сведений, список проверок, график с четырьмя рядами, интервал анализа — участок с газом, поля активны, шаги 1–5 стали доступны (если аппарат Copter). + +**Шаг 2: сверить с FilterReview** + +Открыть тот же лог в `http://127.0.0.1:8000/FilterReview/`. +Ожидание: «Analysis time» Start/End совпадают с «Начало/Конец анализа» мастера; график полёта выглядит так же (крен, тангаж, газ, высота). + +**Шаг 3: проверить интервал** + +Потянуть ползунок под графиком — поля «Начало/Конец» меняются. Ввести в поля значения — график перестраивается. Ввести конец меньше начала — поля откатываются. + +**Шаг 4: проверить окно FFT** + +Стрелками в поле «Окно FFT» — значение ходит по степеням двойки (512 → 1024 → 2048), ниже 64 не опускается. + +**Шаг 5: проверить тёмную тему с данными** + +Переключить тему — график перерисовывается в цветах темы, данные на месте. + +**Шаг 6: исправить найденное** + +Для каждой ошибки в ядре — сначала падающий тест в `tests/tunewizard.test.cjs`, потом правка. Для ошибок в `wizard.js` — правка и повторная ручная проверка. + +Запуск: `node --test tests/tunewizard.test.cjs && npm run lint` +Ожидание: все тесты проходят, линт чистый. + +**Шаг 7: коммит (если были правки)** + +```bash +git add TuneWizard tests/tunewizard.test.cjs +git commit -F - <<'EOF' +TuneWizard: шаг 0 — правки по проверке на реальном логе + +<что именно исправлено> + +Co-Authored-By: Claude Fable 5.1 +EOF +``` + +--- + +### Задача 5: Readme и завершение этапа + +**Файлы:** +- Создать: `TuneWizard/Readme.md` + +**Шаг 1: создать `TuneWizard/Readme.md`** + +```markdown +## Мастер настройки + +Пошаговый мастер настройки Copter по одному логу `.bin`: проверка лога, спектр гироскопов, гармонический notch, низкочастотные фильтры, PID по осям, сводка и файл параметров. Объединяет расчёты FilterReview и PIDReview; оригинальные инструменты не изменяет. + +Дизайн: `docs/plans/2026-10-23-tune-wizard-design.md`. Этапы реализации — в `docs/plans/`. + +### Запуск + +Из корня репозитория: `python -m http.server --bind 127.0.0.1`, затем `http://127.0.0.1:8000/TuneWizard/`. + +### Структура + +- `index.html`, `wizard.js` — страница, состояние, навигация, графики Plotly. +- `core/` — математика и проверки без DOM; те же файлы гоняются под Node: `node --test tests/tunewizard.test.cjs`. +- `logs/` — локальные логи для ручной проверки, в репозиторий не попадают. + +### Готовность + +- Этап 1: каркас, темы, шаг 0 (лог и проверки) — готово. +- Этапы 2–6: спектр и notch, фильтры, PID, сводка, лендинг — в работе. +``` + +**Шаг 2: полная проверка** + +Запуск: `npm test && npm run lint` +Ожидание: все тесты (116 прежних + новые) проходят, линт чистый. + +**Шаг 3: коммит и push** + +```bash +git add TuneWizard/Readme.md +git commit -F - <<'EOF' +TuneWizard: Readme и завершение этапа 1 + +Co-Authored-By: Claude Fable 5.1 +EOF +git push origin tune-wizard +``` + +--- + +## Что сознательно не вошло в этап 1 + +- Память прошлого прохода («предлагалось → в логе сейчас») — читается и пишется на этапе 5 вместе со сводкой. +- Ссылка и иконка на лендинге — этап 6. +- Tippy-подсказки — добавим, когда появятся поля параметров (этап 2). diff --git a/eslint.config.mjs b/eslint.config.mjs index 33228fa7..3f0c85fc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,7 +53,13 @@ const RUNTIME_GLOBALS = { } // Scripts that also run under Node and use CommonJS behind a `typeof module` check. -const DUAL_SCRIPTS = ['AirspeedFit/airspeedfit_core.js', 'SimpleGCS/commands.js'] +const DUAL_SCRIPTS = [ + 'AirspeedFit/airspeedfit_core.js', + 'SimpleGCS/commands.js', + // Ядро мастера настройки: те же скрипты гоняются под Node в tests/tunewizard.test.cjs + 'TuneWizard/core/log.js', + 'TuneWizard/core/checks.js' +] const libraryGlobals = (src) => LIBRARY_GLOBALS[Object.keys(LIBRARY_GLOBALS).find((key) => src.toLowerCase().includes(key))] diff --git a/package.json b/package.json index 8f62050b..44daff9c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "test": "node --test tests/*.test.cjs", "test:browser": "node tests/browser.cjs", "test:video": "node tests/video-browser.cjs", + "test:tunewizard": "node tests/tunewizard-browser.cjs", "lint": "eslint ." }, "engines": { diff --git a/tests/README.md b/tests/README.md index 4cfdb3b2..71b57430 100644 --- a/tests/README.md +++ b/tests/README.md @@ -56,3 +56,11 @@ isolation, preserve the same vehicle's pan/zoom, and recenter after a vehicle ch or explicit disconnect. A duplicated tab with copied session storage must acquire a different component ID. Review validation also removes individual fixes in isolated copies and verifies that the targeted regression tests fail. + +## Мастер настройки (TuneWizard) + +`tunewizard.test.cjs` — тесты ядра `TuneWizard/core` под Node на поддельных логах с интерфейсом парсера +(история параметров, окно полёта по газу, сводка VIBE, проверки пригодности лога). `npm run test:tunewizard` +запускает `tunewizard-browser.cjs`: smoke-прогон шага 0 в настоящем браузере (Playwright, `CHROME_PATH` — путь +к chrome.exe) на локальном логе (`TUNEWIZARD_LOG` или первый `.bin` в `TuneWizard/logs/`); без лога тест +пропускается. Скриншоты пишутся в `TuneWizard/logs/screenshots/`. diff --git a/tests/tunewizard-browser.cjs b/tests/tunewizard-browser.cjs new file mode 100644 index 00000000..d6833bab --- /dev/null +++ b/tests/tunewizard-browser.cjs @@ -0,0 +1,155 @@ +// Smoke-прогон мастера настройки (TuneWizard) в настоящем браузере через Playwright. +// Запуск: npm run test:tunewizard +// Нужны: лог .bin (переменная TUNEWIZARD_LOG или первый .bin в TuneWizard/logs/), браузер +// (CHROME_PATH — путь к chrome.exe; без него Playwright берёт свой Chromium, если он установлен) +// и python для http.server. Без лога тест пропускается с сообщением, чтобы не падать на чистой машине. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { spawn } = require('node:child_process') +const { chromium } = require('playwright') + +const repo = path.resolve(__dirname, '..') +const logs_dir = path.join(repo, 'TuneWizard', 'logs') +const port = 8791 + +function find_log() { + if (process.env.TUNEWIZARD_LOG) { + return process.env.TUNEWIZARD_LOG + } + if (!fs.existsSync(logs_dir)) { + return null + } + const bin = fs.readdirSync(logs_dir).find((name) => name.toLowerCase().endsWith('.bin')) + return bin ? path.join(logs_dir, bin) : null +} + +// Секции шагов, которые реально отображаются: атрибут hidden может быть перебит CSS, поэтому смотрим computed style +function visible_steps(page) { + return page.evaluate(() => [...document.querySelectorAll('section[data-step]')] + .filter((s) => getComputedStyle(s).display !== 'none') + .map((s) => s.dataset.step)) +} + +function step_buttons_disabled(page) { + return page.evaluate(() => [...document.querySelectorAll('#wizbar button')].map((b) => b.disabled)) +} + +async function wait_for_server(page, url, server) { + for (let i = 0; i < 40; i++) { + if (server.exitCode != null) { + throw new Error('http.server завершился с кодом ' + server.exitCode + ' (занят порт ' + port + ' или нет python?)') + } + try { + await page.goto(url, { waitUntil: 'networkidle' }) + return + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } + throw new Error('http.server не поднялся на ' + url) +} + +async function main() { + const log_path = find_log() + if (log_path == null) { + console.log('tunewizard-browser: лог не найден (TUNEWIZARD_LOG или TuneWizard/logs/*.bin) — пропуск') + return + } + const python = process.platform === 'win32' ? 'python' : 'python3' + const problems = [] + let server = null + let browser = null + try { + server = spawn(python, ['-m', 'http.server', '--bind', '127.0.0.1', String(port)], { cwd: repo, stdio: 'ignore' }) + server.on('error', (e) => problems.push('http.server: ' + e.message)) + browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH || undefined }) + const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } }) + page.on('console', (m) => { if (m.type() === 'error') problems.push('console: ' + m.text()) }) + page.on('pageerror', (e) => problems.push('pageerror: ' + e.message)) + await wait_for_server(page, 'http://127.0.0.1:' + port + '/TuneWizard/', server) + + // Каркас до загрузки + assert.equal(await page.locator('#wizbar button').count(), 6, 'шесть кнопок шагов') + assert.deepEqual(await step_buttons_disabled(page), [false, true, true, true, true, true], 'до загрузки доступен только шаг 0') + assert.deepEqual(await visible_steps(page), ['0'], 'видна только секция шага 0') + assert.deepEqual(await page.evaluate(() => ['log_verdict', 'log_info', 'log_checks'].filter((id) => getComputedStyle(document.getElementById(id)).display !== 'none')), [], 'блоки результата скрыты до загрузки') + + // Тема + await page.click('.themebar button[data-t="dark"]') + assert.equal(await page.evaluate(() => document.documentElement.getAttribute('data-theme')), 'dark') + assert.equal(await page.evaluate(() => localStorage.getItem('theme_v1')), 'dark') + await page.click('.themebar button[data-t="light"]') + + // Загрузка лога + await page.setInputFiles('#fileItem', log_path) + await page.locator('#log_verdict').waitFor({ state: 'visible', timeout: 180000 }) + await page.waitForFunction(() => document.getElementById('loading').style.visibility === 'hidden', null, { timeout: 180000 }) + assert.match(await page.title(), /Мастер настройки: /) + assert.match(await page.locator('#log_verdict').getAttribute('class'), /verdict--(ok|warn|fail)/) + assert.ok((await page.locator('#log_info .stat').count()) >= 9, 'плитки сведений') + const count = await page.locator('#log_checks .check').count() + assert.ok(count >= 7 && count <= 9, 'от 7 до 9 проверок (clipping и газ висения зависят от лога)') + const titles = await page.evaluate(() => [...document.querySelectorAll('#log_checks .check h3')].map((h) => h.textContent)) + for (const title of ['Аппарат', 'Сообщения PID', 'Данные гироскопа', 'Источник трекинга notch', 'Полёт', 'Вибрации']) { + assert.ok(titles.includes(title), 'есть проверка «' + title + '»') + } + const statuses = await page.evaluate(() => [...document.querySelectorAll('#log_checks .check')].map((c) => c.className.replace('check check--', ''))) + for (const status of statuses) { + assert.ok(['ok', 'warn', 'fail'].includes(status), 'статус проверки: ' + status) + } + + // Интервал анализа и график + const start = Number(await page.inputValue('#TimeStart')) + const end = Number(await page.inputValue('#TimeEnd')) + assert.ok(end > start, 'интервал по умолчанию не пуст') + assert.deepEqual(await page.evaluate(() => document.getElementById('FlightData').layout.xaxis.range), [start, end], 'график показывает интервал анализа') + assert.ok(await page.evaluate(() => document.getElementById('FlightData').data.some((t) => t.x.length > 0)), 'на графике есть данные') + // Конец меньше начала — откат + await page.fill('#TimeEnd', String(start - 1)) + await page.dispatchEvent('#TimeEnd', 'change') + assert.equal(Number(await page.inputValue('#TimeEnd')), end, 'неверный интервал откатывается') + // Окно FFT — степени двойки + await page.fill('#FFTWindow_size', '1025') + await page.dispatchEvent('#FFTWindow_size', 'change') + assert.equal(await page.inputValue('#FFTWindow_size'), '2048', 'окно FFT округляется к степени двойки') + + // Шаги и тема с данными + assert.deepEqual(await step_buttons_disabled(page), [false, false, false, false, false, false], 'после загрузки лога Copter доступны все шаги') + await page.click('#wizbar button:nth-child(2)') + assert.deepEqual(await visible_steps(page), ['1']) + await page.click('#wizbar button:nth-child(1)') + assert.deepEqual(await visible_steps(page), ['0']) + await page.click('.themebar button[data-t="dark"]') + const surface = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--surface').trim()) + assert.equal(await page.evaluate(() => document.getElementById('FlightData').layout.paper_bgcolor), surface, 'график перекрашен под тёмную тему') + + // Скриншоты для глаз — рядом с логами, в репозиторий не попадают + const shots = path.join(logs_dir, 'screenshots') + fs.mkdirSync(shots, { recursive: true }) + await page.screenshot({ path: path.join(shots, 'step0_dark.png'), fullPage: true }) + await page.click('.themebar button[data-t="light"]') + await page.screenshot({ path: path.join(shots, 'step0_light.png'), fullPage: true }) + + assert.deepEqual(problems, [], 'ошибки консоли') + console.log('tunewizard-browser: шаг 0 пройден, проверок ' + statuses.length + ' (' + statuses.join(', ') + '), скриншоты в ' + shots) + } catch (e) { + // При падении показать накопленные сообщения консоли и сервера — иначе причина теряется + if (problems.length) { + console.error('Сообщения консоли:', problems) + } + throw e + } finally { + if (browser) { + await browser.close() + } + if (server) { + server.kill() + } + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/tests/tunewizard.test.cjs b/tests/tunewizard.test.cjs new file mode 100644 index 00000000..6b0c25a4 --- /dev/null +++ b/tests/tunewizard.test.cjs @@ -0,0 +1,361 @@ +// Тесты ядра мастера настройки (TuneWizard/core). Логи — поддельные объекты с интерфейсом парсера. +const test = require('node:test') +const assert = require('node:assert/strict') + +const core_log = require('../TuneWizard/core/log.js') + +// Поддельный лог с интерфейсом парсера. +// messages = { ИМЯ: { поле: массив } } — сообщение без экземпляров; +// { ИМЯ: { instances: { 0: { поле: массив }, 1: {…} } } } — с экземплярами (как VIBE, ESC, IMU): +// такие сообщения настоящий парсер отдаёт только через get_instance, а get() на них падает. +// params = [[имя, значение, TimeUS], ...] +function fake_log({ messages = {}, params = [] } = {}) { + const types = { ...messages } + if (params.length > 0) { + types.PARM = { + TimeUS: params.map((p) => p[2] ?? 0), + Name: params.map((p) => p[0]), + Value: params.map((p) => p[1]) + } + } + const messageTypes = {} + for (const [name, msg] of Object.entries(types)) { + messageTypes[name] = msg.instances ? { instances: Object.fromEntries(Object.keys(msg.instances).map((k) => [k, {}])) } : {} + } + return { + messageTypes, + get(name, field) { + return this.get_instance(name, null, field) + }, + get_instance(name, instance, field) { + const msg = types[name] + if (msg == null) return undefined + if (msg.instances) { + if (instance == null) throw new TypeError("сообщение с экземплярами читается только через get_instance") + const data = msg.instances[instance] + if (data == null) return undefined + return field ? data[field] : data + } + return field ? msg[field] : msg + } + } +} + +const copter_version = { build_type: 2, fw_string: 'ArduCopter V4.6.0', flight_controller: 'CubeOrange', filter_version: 2 } + +test('log_params: последнее значение, история изменений, отсутствующий параметр', () => { + const log = fake_log({ params: [ + ['ATC_RAT_RLL_P', 0.1, 1e6], + ['ATC_RAT_RLL_P', 0.1, 2e6], // повтор без изменения не попадает в историю + ['ATC_RAT_RLL_P', 0.15, 30e6], + ['INS_GYRO_FILTER', 40, 1e6] + ] }) + const params = core_log.log_params(log) + assert.equal(params.get('ATC_RAT_RLL_P'), 0.15) + assert.equal(params.get('INS_GYRO_FILTER'), 40) + assert.equal(params.get('NOPE'), null) + assert.equal(params.has('NOPE'), false) + assert.deepEqual(params.history('ATC_RAT_RLL_P'), [{ time: 1, value: 0.1 }, { time: 30, value: 0.15 }]) + assert.equal(params.changed('ATC_RAT_RLL_P'), true) + assert.equal(params.changed('INS_GYRO_FILTER'), false) +}) + +test('log_messages: флаги наличия сообщений', () => { + const log = fake_log({ messages: { PIDR: {}, PIDP: {}, PIDY: {}, GYR: {}, ESC: {}, ATT: {} } }) + const m = core_log.log_messages(log) + assert.equal(m.pid, true) + assert.equal(m.rate, false) + assert.equal(m.gyro_raw, true) + assert.equal(m.gyro_batch, false) + assert.equal(m.esc, true) + assert.equal(m.rpm, false) + assert.equal(m.vibe, false) +}) + +test('throttle_window: от первого до последнего ненулевого газа с отступом 1 с', () => { + const time = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + const throttle = [0, 0, 0.3, 0.4, 0.4, 0.4, 0.4, 0.4, 0.3, 0, 0] + assert.deepEqual(core_log.throttle_window(time, throttle), { start: 3, end: 7 }) + assert.equal(core_log.throttle_window(time, new Array(11).fill(0)), null) + // Слишком короткий полёт: окно схлопывается + assert.equal(core_log.throttle_window([0, 1, 2], [0, 0.5, 0]), null) +}) + +test('mean_throttle: средний ненулевой газ внутри окна', () => { + const time = [0, 1, 2, 3, 4, 5] + const throttle = [0, 0.2, 0.4, 0.6, 0, 0.9] + // Сумма чисел с плавающей точкой: сравниваем с допуском + assert.ok(Math.abs(core_log.mean_throttle(time, throttle, { start: 1, end: 4 }) - 0.4) < 1e-9) + assert.equal(core_log.mean_throttle(time, [0, 0, 0, 0, 0, 0], { start: 1, end: 4 }), null) +}) + +test('throttle_series: CTUN.ThO предпочтительнее RATE.AOut', () => { + const both = fake_log({ messages: { + CTUN: { TimeUS: [0, 1e6], ThO: [0.3, 0.4] }, + RATE: { TimeUS: [0, 1e6], AOut: [0.5, 0.6] } + } }) + assert.deepEqual(core_log.throttle_series(both), { time: [0, 1], throttle: [0.3, 0.4], source: 'CTUN.ThO' }) + const rate_only = fake_log({ messages: { RATE: { TimeUS: [0, 2e6], AOut: [0.5, 0.6] } } }) + assert.deepEqual(core_log.throttle_series(rate_only), { time: [0, 2], throttle: [0.5, 0.6], source: 'RATE.AOut' }) + assert.equal(core_log.throttle_series(fake_log()), null) +}) + +test('throttle_series: CTUN без поля ThO — берём RATE.AOut; без обоих полей — null', () => { + const log = fake_log({ messages: { + CTUN: { TimeUS: [0, 1e6], Alt: [1, 2] }, + RATE: { TimeUS: [0, 1e6], AOut: [0.5, 0.6] } + } }) + assert.deepEqual(core_log.throttle_series(log), { time: [0, 1], throttle: [0.5, 0.6], source: 'RATE.AOut' }) + const no_fields = fake_log({ messages: { CTUN: { TimeUS: [0] }, RATE: { TimeUS: [0] } } }) + assert.equal(core_log.throttle_series(no_fields), null) +}) + +test('percentile: 95-й процентиль', () => { + const values = Array.from({ length: 100 }, (_, i) => i + 1) + assert.equal(core_log.percentile(values, 0.95), 95) + assert.equal(core_log.percentile([], 0.95), null) +}) + +test('vibe_summary: по экземплярам IMU — p95 и максимум по осям, clipping', () => { + const log = fake_log({ messages: { VIBE: { instances: { + 0: { TimeUS: [0, 2e6], VibeX: [10, 12], VibeY: [5, 7], VibeZ: [30, 35], Clip: [0, 3] }, + 1: { TimeUS: [1e6, 3e6], VibeX: [20, 22], VibeY: [6, 8], VibeZ: [40, 45], Clip: [0, 1] } + } } } }) + const vibe = core_log.vibe_summary(log) + assert.equal(vibe.imus.length, 2) + assert.equal(vibe.imus[0].imu, 0) + assert.equal(vibe.imus[0].max, 35) + assert.equal(vibe.imus[1].max, 45) + assert.equal(vibe.imus[0].clip, 3) + assert.equal(vibe.imus[1].clip, 1) + assert.equal(vibe.max, 45) + assert.equal(vibe.clip, 4) + assert.equal(vibe.level, 45) + assert.equal(core_log.vibe_summary(fake_log()), null) +}) + +test('vibe_summary: старый формат VIBE без экземпляров — один IMU, clip = сумма Clip0..Clip2', () => { + const log = fake_log({ messages: { VIBE: { + TimeUS: [0, 1e6, 2e6], VibeX: [10, 12, 11], VibeY: [5, 7, 6], VibeZ: [30, 35, 33], + Clip0: [0, 1, 2], Clip1: [0, 0, 1], Clip2: [0, 0, 0] + } } }) + const vibe = core_log.vibe_summary(log) + assert.equal(vibe.imus.length, 1) + assert.equal(vibe.imus[0].imu, 0) + assert.equal(vibe.imus[0].max, 35) + assert.equal(vibe.imus[0].clip, 3) + assert.equal(vibe.clip, 3) +}) + +test('vibe_summary: VIBE по экземплярам без поля Clip — clip равен 0, без исключения', () => { + const log = fake_log({ messages: { VIBE: { instances: { + 0: { TimeUS: [0, 1e6], VibeX: [10, 12], VibeY: [5, 7], VibeZ: [30, 35] } + } } } }) + const vibe = core_log.vibe_summary(log) + assert.equal(vibe.imus[0].clip, 0) + assert.equal(vibe.clip, 0) + assert.equal(vibe.max, 35) +}) + +test('vibe_summary: длинный ряд VIBE (часы полёта) не переполняет стек', () => { + const n = 200000 + const log = fake_log({ messages: { VIBE: { instances: { 0: { + TimeUS: Array.from({ length: n }, (_, i) => i * 1e5), + VibeX: new Array(n).fill(10), + VibeY: new Array(n).fill(10), + VibeZ: Array.from({ length: n }, (_, i) => (i == n - 1) ? 50 : 20), + Clip: new Array(n).fill(0) + } } } } }) + const vibe = core_log.vibe_summary(log) + assert.equal(vibe.max, 50) + assert.equal(vibe.level, 20) +}) + +test('log_info: аппарат, длительность, окно полёта, средний газ', () => { + const n = 61 + const TimeUS = Array.from({ length: n }, (_, i) => i * 1e6) + const ThO = TimeUS.map((_, i) => (i >= 5 && i <= 55) ? 0.35 : 0) + const log = fake_log({ messages: { + ATT: { TimeUS, Roll: new Array(n).fill(0), Pitch: new Array(n).fill(0) }, + CTUN: { TimeUS, ThO }, + GYR: {}, PIDR: {}, PIDP: {}, PIDY: {} + } }) + const info = core_log.log_info(log, copter_version) + assert.equal(info.vehicle, 'Copter') + assert.equal(info.fw_string, 'ArduCopter V4.6.0') + assert.equal(info.board, 'CubeOrange') + assert.equal(info.filter_version, 2) + assert.equal(info.duration_s, 60) + assert.equal(info.time_start, 0) + assert.equal(info.time_end, 60) + assert.equal(info.throttle_source, 'CTUN.ThO') + assert.equal(info.flight.start, 6) + assert.equal(info.flight.end, 54) + assert.equal(info.flight.duration_s, 48) + assert.ok(Math.abs(info.flight.mean_throttle - 0.35) < 1e-9) + assert.equal(info.messages.pid, true) +}) + +test('log_info: без полёта и без ATT', () => { + const log = fake_log({ messages: { RATE: { TimeUS: [0, 1e6, 2e6], AOut: [0, 0, 0] } } }) + const info = core_log.log_info(log, { build_type: 3 }) + assert.equal(info.vehicle, 'Plane') + assert.equal(info.flight, null) + assert.equal(info.duration_s, 2) + assert.equal(info.time_start, 0) + assert.equal(info.time_end, 2) +}) + +test('log_info: ATT объявлен, но без строк (нет полей) — длительность из RATE, без исключения', () => { + // Парсер отдаёт undefined для поля сообщения без строк; поддельный лог ведёт себя так же + const log = fake_log({ messages: { ATT: {}, RATE: { TimeUS: [0, 1e6, 2e6, 3e6], AOut: [0, 0, 0, 0] } } }) + const info = core_log.log_info(log, copter_version) + assert.equal(info.duration_s, 3) + assert.equal(info.time_start, 0) + assert.equal(info.time_end, 3) +}) + +const checks = require('../TuneWizard/core/checks.js') + +// Сведения о хорошем логе Copter; тесты переопределяют нужные поля +function good_info(over = {}) { + return { + vehicle: 'Copter', fw_string: 'ArduCopter V4.6.0', board: 'CubeOrange', filter_version: 2, duration_s: 120, + messages: { pid: true, rate: true, gyro_raw: true, gyro_batch: false, esc: true, rpm: false, vibe: true, ctun: true, att: true, pos: true, ftn: false }, + throttle_source: 'CTUN.ThO', + flight: { start: 5, end: 100, duration_s: 95, mean_throttle: 0.33 }, + ...over + } +} +function fake_params(values) { + return { get: (name) => (name in values) ? values[name] : null, has: (name) => name in values } +} +const good_params = fake_params({ MOT_THST_HOVER: 0.35, MOT_HOVER_LEARN: 2, INS_RAW_LOG_OPT: 8, INS_GYRO_FILTER: 40 }) +const good_vibe = { imus: [{ imu: 0, level: 12, max: 20, clip: 0 }], level: 12, max: 20, clip: 0 } + +function by_id(list, id) { + const found = list.find((c) => c.id === id) + assert.ok(found, `нет проверки ${id}`) + return found +} + +test('run_checks: хороший лог — все проверки ok', () => { + const list = checks.run_checks(good_info(), good_params, good_vibe) + for (const c of list) assert.equal(c.status, 'ok', `${c.id}: ${c.text}`) + // Полный набор проверок в порядке показа + assert.deepEqual(list.map((c) => c.id), ['vehicle', 'pid_log', 'gyro_log', 'pre_post', 'tracking', 'flight', 'vibe', 'clipping', 'hover_throttle']) + assert.equal(checks.checks_summary(list).status, 'ok') +}) + +test('run_checks: не Copter — fail, итог fail', () => { + const list = checks.run_checks(good_info({ vehicle: 'Plane' }), good_params, good_vibe) + assert.equal(by_id(list, 'vehicle').status, 'fail') + assert.equal(checks.checks_summary(list).status, 'fail') +}) + +test('run_checks: PID — ok / warn (только RATE) / fail', () => { + const only_rate = good_info({ messages: { ...good_info().messages, pid: false, rate: true } }) + assert.equal(by_id(checks.run_checks(only_rate, good_params, good_vibe), 'pid_log').status, 'warn') + const none = good_info({ messages: { ...good_info().messages, pid: false, rate: false } }) + assert.equal(by_id(checks.run_checks(none, good_params, good_vibe), 'pid_log').status, 'fail') +}) + +test('run_checks: нет данных гироскопа — fail с подсказкой', () => { + const info = good_info({ messages: { ...good_info().messages, gyro_raw: false, gyro_batch: false } }) + const c = by_id(checks.run_checks(info, good_params, good_vibe), 'gyro_log') + assert.equal(c.status, 'fail') + assert.match(c.fix, /INS_LOG_BAT_MASK/) +}) + +test('run_checks: raw pre/post по INS_RAW_LOG_OPT', () => { + const post_only = fake_params({ INS_RAW_LOG_OPT: 4 }) + const c = by_id(checks.run_checks(good_info(), post_only, good_vibe), 'pre_post') + assert.equal(c.status, 'warn') + // Совет добавляет бит, а не перезаписывает параметр: иначе пропадут другие включённые биты + assert.match(c.fix, /\+8/) + const pre_only = fake_params({ INS_RAW_LOG_OPT: 0 }) + assert.equal(by_id(checks.run_checks(good_info(), pre_only, good_vibe), 'pre_post').status, 'ok') +}) + +test('run_checks: batch pre/post по INS_LOG_BAT_OPT', () => { + const info = good_info({ messages: { ...good_info().messages, gyro_raw: false, gyro_batch: true } }) + assert.equal(by_id(checks.run_checks(info, fake_params({ INS_LOG_BAT_OPT: 4 }), good_vibe), 'pre_post').status, 'ok') + const c = by_id(checks.run_checks(info, fake_params({ INS_LOG_BAT_OPT: 2 }), good_vibe), 'pre_post') + assert.equal(c.status, 'warn') + assert.match(c.fix, /\+4/) + // Только до фильтров — норма, оценка «станет» строится расчётом + assert.equal(by_id(checks.run_checks(info, fake_params({ INS_LOG_BAT_OPT: 0 }), good_vibe), 'pre_post').status, 'ok') +}) + +test('run_checks: источник трекинга — ESC ok, RPM ok, только газ warn', () => { + const rpm = good_info({ messages: { ...good_info().messages, esc: false, rpm: true } }) + assert.equal(by_id(checks.run_checks(rpm, good_params, good_vibe), 'tracking').status, 'ok') + const throttle = good_info({ messages: { ...good_info().messages, esc: false, rpm: false } }) + assert.equal(by_id(checks.run_checks(throttle, good_params, good_vibe), 'tracking').status, 'warn') +}) + +test('run_checks: полёт — нет газа fail, короткий warn', () => { + assert.equal(by_id(checks.run_checks(good_info({ flight: null }), good_params, good_vibe), 'flight').status, 'fail') + const short = good_info({ flight: { start: 5, end: 25, duration_s: 20, mean_throttle: 0.33 } }) + assert.equal(by_id(checks.run_checks(short, good_params, good_vibe), 'flight').status, 'warn') + // Без CTUN и RATE газ вообще не определить — текст говорит об этом, а не о «нулевом газе» + const no_source = by_id(checks.run_checks(good_info({ flight: null, throttle_source: null }), good_params, good_vibe), 'flight') + assert.equal(no_source.status, 'fail') + assert.match(no_source.text, /CTUN и RATE/) +}) + +test('run_checks: вибрации — пороги 30/60 и clipping', () => { + const warn = { ...good_vibe, level: 45 } + assert.equal(by_id(checks.run_checks(good_info(), good_params, warn), 'vibe').status, 'warn') + const fail = { ...good_vibe, level: 70 } + assert.equal(by_id(checks.run_checks(good_info(), good_params, fail), 'vibe').status, 'fail') + const clip = { ...good_vibe, clip: 5 } + assert.equal(by_id(checks.run_checks(good_info(), good_params, clip), 'clipping').status, 'warn') + assert.equal(by_id(checks.run_checks(good_info(), good_params, null), 'vibe').status, 'warn') + // Рядом с порогом уровень печатается с одной десятичной: 59.6 — warn, и не показывается как «60» + const near = by_id(checks.run_checks(good_info(), good_params, { ...good_vibe, level: 59.6 }), 'vibe') + assert.equal(near.status, 'warn') + assert.match(near.text, /59\.6/) + // Некорректный уровень (NaN) — warn без исключения, clipping проверяется как обычно + const broken = checks.run_checks(good_info(), good_params, { ...good_vibe, level: NaN }) + assert.equal(by_id(broken, 'vibe').status, 'warn') + assert.equal(by_id(broken, 'clipping').status, 'ok') +}) + +test('run_checks: газ висения против MOT_THST_HOVER', () => { + const far = fake_params({ MOT_THST_HOVER: 0.5, MOT_HOVER_LEARN: 0 }) + const c = by_id(checks.run_checks(good_info(), far, good_vibe), 'hover_throttle') + assert.equal(c.status, 'warn') + assert.match(c.fix, /MOT_HOVER_LEARN/) + const close = fake_params({ MOT_THST_HOVER: 0.31 }) + assert.equal(by_id(checks.run_checks(good_info(), close, good_vibe), 'hover_throttle').status, 'ok') +}) + +test('run_checks: газ висения ниже предела обучения 0.125 — предупреждение упоминает предел', () => { + const info = good_info({ flight: { start: 5, end: 100, duration_s: 95, mean_throttle: 0.11 } }) + const params = fake_params({ MOT_THST_HOVER: 0.3, MOT_HOVER_LEARN: 0 }) + const c = by_id(checks.run_checks(info, params, good_vibe), 'hover_throttle') + assert.equal(c.status, 'warn') + assert.match(c.text, /0\.125/) +}) + +test('run_checks: газ висения — расхождение считается к достижимому обученному значению', () => { + // Средний газ ниже предела: обучение дало бы ровно 0.125, и MOT_THST_HOVER = 0.125 — норма, а не расхождение + const low = good_info({ flight: { start: 5, end: 100, duration_s: 95, mean_throttle: 0.05 } }) + const at_limit = by_id(checks.run_checks(low, fake_params({ MOT_THST_HOVER: 0.125, MOT_HOVER_LEARN: 0 }), good_vibe), 'hover_throttle') + assert.equal(at_limit.status, 'ok') + assert.match(at_limit.text, /0\.125/) + // Обучение включено, но значение далеко от газа: висения в AltHold было мало + const learning = by_id(checks.run_checks(good_info(), fake_params({ MOT_THST_HOVER: 0.5, MOT_HOVER_LEARN: 2 }), good_vibe), 'hover_throttle') + assert.equal(learning.status, 'warn') + assert.match(learning.fix, /AltHold/) +}) + +test('checks_summary: счётчики', () => { + const s = checks.checks_summary([{ status: 'ok' }, { status: 'warn' }, { status: 'warn' }]) + assert.deepEqual(s, { status: 'warn', ok: 1, warn: 2, fail: 0 }) +}) + +test('checks_summary: пустой список — ok с нулевыми счётчиками', () => { + assert.deepEqual(checks.checks_summary([]), { status: 'ok', ok: 0, warn: 0, fail: 0 }) +})