Один лог — шесть шагов: проверка лога, спектр гироскопов, гармонический notch, низкочастотные фильтры, PID по осям и сводка с файлом параметров. На каждом шаге мастер предлагает значения, вы их правите и подтверждаете.
+
+
+
+
+
+
00 · Лог и проверки
Загрузите лог полёта
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Интервал анализа задаётся ползунком под графиком полёта или полями выше. По умолчанию — участок с ненулевым газом с отступом по секунде с каждой стороны. Окно FFT — степень двойки.
+
+
+
+
+
+
+
Полёт
Крен, тангаж, газ и высота
+
+
+
+
+
01 · Спектр до фильтров
Шаг появится на этапе 2
+
+
+
02 · Гармонический notch
Шаг появится на этапе 2
+
+
+
03 · Низкочастотные фильтры
Шаг появится на этапе 3
+
+
+
04 · PID по осям
Шаг появится на этапе 4
+
+
+
05 · Сводка
Шаг появится на этапе 5
+
+
+
+
+
+
+
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 '
'
+ ).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 — степень двойки.