# Авто-яркость с обучаемой кривой — как это устроено внутри Документ для тех, кому интересна математика и инженерия фичи «Авто-яркость по датчику» (Настройки → Экран). Пользовательское описание — в [README](../README.ru.md#авто-яркость-по-датчику); здесь — почему выбраны именно такие формулы, какие инварианты держатся и что выяснилось про сам датчик. Реализация: [`BrightnessCurve.cs`](../src/SystemIntegration/BrightnessCurve.cs) (чистая математика, под юнит-тестами), [`AutoBrightnessGuard.cs`](../src/SystemIntegration/AutoBrightnessGuard.cs) (машина состояний), [`AlsSensor.cs`](../src/SystemIntegration/AlsSensor.cs) (датчик). --- ## 0. Постановка задачи Нужна функция «сколько процентов яркости при таком освещении»: ``` B : lux → percent, lux ∈ [0, ∞), percent ∈ [0, 100] ``` Но универсальной такой функции **не существует**: комфортная яркость зависит от человека, экрана, времени суток, усталости. Поэтому `B` не задаётся раз и навсегда, а **обучается на поправках пользователя** — при этом оставаясь объяснимой: на вопрос «почему экран стал таким» всегда есть точный ответ (вот две ближайшие якорные точки, вот интерполяция между ними). Осознанно **не берём нейросеть**: модель одной переменной, которую нельзя объяснить и отладить, — плохой обмен. Все зрелые open-source решения (wluma, Clight, gnome-adaptive-brightness) и даже Android Adaptive Brightness по сути делают то же самое — корректируют кривую по правкам, а не «думают». --- ## 1. Почему логарифм Восприятие света человеком **логарифмическое** (закон Вебера–Фехнера): разница между 10 и 20 лк ощущается примерно так же, как между 500 и 1000 лк — то есть значим не *разностный*, а *кратный* прирост. Поэтому всё — интерполяция, гистерезис, склейка точек — считается не в люксах, а в лог-шкале: ``` L(lux) = log₁₀(1 + max(0, lux)) ``` Единица `+1` внутри логарифма нужна, чтобы кромешная тьма (0 лк) не улетала в −∞: `L(0) = 0`, `L(9) = 1`, `L(99) = 2`, `L(9999) = 4`. Заодно это делает шкалу конечной на всём рабочем диапазоне (0 … ~100 000 лк → `L` ∈ [0, 5]). --- ## 2. Кривая: якорные точки и интерполяция Кривая — это множество якорей `P = {(luxᵢ, pctᵢ)}`, из которого предсказание строится кусочно-линейно **в лог-шкале**. Для `lux` между соседними якорями `a` и `b`: ``` t = (L(lux) − L(a.lux)) / (L(b.lux) − L(a.lux)), t ∈ [0, 1] B(lux) = round( a.pct + t · (b.pct − a.pct) ) ``` За крайними якорями кривая **горизонтальна** (`B = pct` крайнего) — экстраполировать за пределы пережитого опыта мы не беремся: за самым ярким якорем логичнее держать его же значение, чем уезжать в бесконечность. Кривая по умолчанию (`BrightnessCurve.DefaultPoints`) — шесть якорей, покрывающих типичные сцены: | lux | % | сцена | |----:|--:|-------| | 0 | 10 | полная темнота | | 10 | 25 | ночник, сумерки | | 50 | 40 | тускло освещённая комната | | 200 | 60 | обычное офисное освещение | | 700 | 80 | очень светлая комната, окно | | 2000 | 100 | улица, прямой дневной свет | Она работает сразу, без обучения; дальше пользователь «протаптывает» её под себя. --- ## 3. Обучение: три правила вытеснения Правка яркости, сделанная человеком и «отстоявшаяся» (см. §5), становится якорем `(lux*, pct*)`. Перед вставкой из множества удаляются точки, противоречащие новому знанию. Правил три, и каждое имеет физический смысл. Пусть `p` — существующая точка: ``` (R1) p.lux ≤ lux* и p.pct ≥ pct* → удалить «при более тусклом свете просили ту же или бо́льшую яркость» — теперь опровергнуто (R2) p.lux ≥ lux* и p.pct ≤ pct* → удалить «при более ярком свете просили ту же или меньшую яркость» — тоже опровергнуто (R3) |L(p.lux) − L(lux*)| < δ → удалить «это те же самые условия» (δ — порог различимости, см. §4): два мнения об одном и том же не хранятся, побеждает последнее ``` ### Инвариант монотонности **Утверждение.** После любого числа обучений множество якорей строго монотонно: для любых `p, q ∈ P`: `p.lux < q.lux ⟹ p.pct < q.pct`. **Доказательство** (индукция по числу обучений). База: кривая по умолчанию строго монотонна. Шаг: пусть перед вставкой инвариант держался. Правило R1 удаляет все точки слева-или-вровень с яркостью ≥ `pct*`, значит все выжившие слева имеют `pct < pct*`. Правило R2 симметрично: все выжившие справа имеют `pct > pct*`. Точка ровно на `lux*` удаляется любым из правил (или R3). Значит новая точка вставляется в строго возрастающую последовательность, не нарушая её. ∎ **Следствие 1: «пила» невозможна.** Кривая нигде не убывает — ситуация «стало светлее, а экран потускнел» исключена математически, а не эвристикой. Это же проверяет юнит-тест `Predict_BetweenAnchors_InterpolatesMonotonically`. **Следствие 2: число якорей ограничено.** Из монотонности по процентам `|P| ≤ 101`; из правила R3 соседи разнесены минимум на `δ` в лог-шкале, что для рабочего диапазона даёт `|P| ≲ 5/δ ≈ 50`. На практике живёт 5–15 точек. Поэтому «бесконечно растущая модель» — не наш случай, и ограничивать обучение по времени не требуется: **система самоограничена по построению**. Предсказание — `O(n log n)` при `n ≲ 15`, то есть микросекунды. ### Файн-тюнинг: уточняющая правка сдвигает, а не заменяет Клавишами Windows яркость меняется **шагами по 10 %** (само железо принимает все 101 уровень — проверено, `WmiMonitorBrightness.Level`; ограничение чисто программное). Строго говоря, промежуточное значение достижимо — ползунком в быстрых настройках, — но им пользуются заметно реже: клавиши под рукой, а ползунок надо открыть, попасть и потянуть. На практике человек жмёт клавишу и начинает колебаться: 60, потом 50, снова 60… Если такую правку записывать буквально, кривая скачет вслед за ним на всю ступень. Поэтому правка **в пределах одной ступени** (`fineStep`, по умолчанию 10 %) не заменяет прежнее мнение об этих условиях, а сдвигает его навстречу: ``` pct_new = round( pct_old + β · (pct_user − pct_old) ), β = 0.5 (AwayFromZero) ``` Колебание 65 / 55 сходится примерно к 60 — то есть к значению, которое **кривая выставить может, а клавиши нет**. Побочный эффект приятнее основного: у колебаний исчезает причина, и система обучения перестаёт осциллировать сама по себе. Крупная правка (было 70, стало 40) — осознанная смена, а не поиск середины: она записывается точно, без сглаживания. Округление — обязательно `AwayFromZero`, а не банковское: при `.5` посередине банковское округление к чётному оставляло бы `50.5 → 50`, и настойчивый пользователь застревал бы на полпути, не в силах довести кривую до своего значения. С `AwayFromZero` повтор одного и того же выбора доводит якорь ровно до него за 3–4 правки — **настойчивость побеждает**. > **Известный компромисс.** Сглаживание не различает источник правки: мелкое движение > ползунком (например, точно выставленные 57 %) тоже считается уточняющим и усредняется с > прежним мнением. Различить источник технически можно — шаг клавиши даёт дельту ровно ±10, > ползунок произвольную, — но пока не делаем: усложнение ради редкого сценария, а повтор > всё равно доводит кривую до желаемого. Если на практике будет мешать — правило легко > сузить до «дельта ≈ шаг клавиш». ### Зачем нужно R3 (история из практики) R1 и R2 запрещают немонотонность, но не запрещают **обрыв**: понедельник, 100 лк, ставим 40% (устали); вторник, 110 лк, ставим 70% (бодры). Обе точки монотонны — обе выживают, и между неразличимыми глазом условиями вырастает почти вертикальная ступень 40 → 70. Правило R3 закрывает это по построению: раз фича не может отличить 100 лк от 110 (порог срабатывания тот же `δ`), она не имеет права хранить о них два разных мнения. Тонкость: обрывы **самозалечиваются** и без R3 — некомфортный скачок вызывает правку, которая ложится внутрь обрыва и дробит его на пологие ступени. R3 просто не даёт им появляться. --- ## 4. Гистерезис: когда вообще реагировать Изменение освещённости считается значимым, если в лог-шкале оно не меньше порога `δ` (`AutoBrightnessHysteresis`, по умолчанию **0.1**): ``` significant(lux₁ → lux₂) ⟺ |L(lux₂) − L(lux₁)| ≥ δ ``` `δ = 0.1` — это множитель `10^0.1 ≈ 1.26`, то есть **±26 % люксов**. Свойство лог-шкалы здесь ровно то, что нужно: один и тот же порог одинаково строг и в темноте (18 → 23 лк незначимо), и на свету (800 → 1000 лк тоже незначимо). Тот же `δ` используется как порог склейки в R3 — это не совпадение, а принцип: **неразличимое для триггера неразличимо и для памяти**. --- ## 5. Конвейер: четыре эшелона против дёрганья Между сырым сэмплом датчика и движением яркости стоят четыре независимых фильтра. ``` сэмпл ~раз в 1.5 с │ ┌───────────────▼───────────────┐ │ 1. МЕДИАНА окна «инерции» │ случайный блик не сдвигает медиану вообще └───────────────┬───────────────┘ ┌───────────────▼───────────────┐ │ 2. ГИСТЕРЕЗИС δ (лог-шкала) │ мелкий дрейф света не считается изменением └───────────────┬───────────────┘ ┌───────────────▼───────────────┐ │ 3. ДЕБАУНС стабилизации 2 с │ нестабильный свет откладывает решение └───────────────┬───────────────┘ ┌───────────────▼───────────────┐ │ 4. МЁРТВАЯ ЗОНА 5 % │ ради пары процентов экран не трогаем └───────────────┬───────────────┘ плавный ход ~10 с ``` ### Почему медиана, а не среднее У датчика нет интегрирующей сферы: блик, фара, тень руки дают одиночный всплеск в сотни люксов. Среднее такой выброс утащит за собой (одно значение 9000 в окне из шести двухсот поднимает среднее втрое), **медиана не сдвинется вообще** — у неё точка излома 50 %: чтобы её сместить, изменение должно продержаться больше половины окна. Ровно то поведение, которое нужно: «реагируем на новую обстановку, игнорируем происшествия». Окно задаётся в UI («Инерция датчика»: 0 / 5 / 10 / 20 / 30 / 60 с, по умолчанию 10 с) и скользит по времени, а не по числу сэмплов — так поведение не зависит от того, как часто датчик отдаёт данные. ### Чего медиана НЕ умеет Если свет **устойчиво скачет** между двумя значениями (граница света и тени, мерцающий источник) — это не выброс, а бимодальный сигнал, и медиана сядет на то значение, которого в окне большинство. Здесь работает эшелон 3: каждое значимое изменение перезапускает двухсекундное окно стабилизации, и пока сигнал мечется, решение просто не принимается — экран стоит. Это не зависание, а осознанный отказ реагировать на нестабильность. --- ## 6. Кто автор изменения яркости Ключевая техническая проблема: наши собственные записи яркости порождают ровно такие же системные события, как движение ползунка пользователем. Спутать их — значит либо «учиться у себя» (кривая уползёт), либо остановить собственный плавный ход на первом же шаге. Решение — метки в [`Brightness.Own`](../src/SystemIntegration/Brightness.cs): перед каждой своей записью значение помечается, событие с таким значением считается своим. Метки живут по TTL и **не снимаются при проверке**: WMI-события дублируются, и «одноразовая» метка превращала дубль нашей же записи в «действие пользователя» (ловили вживую — схождение лимита замерзало ложной паузой). Разбор события — в `PowerProfileGuard.OnBrightnessChanged`, единственном подписчике `BrightnessWatcher`: он классифицирует событие и раздаёт трём потребителям — запоминанию яркости, лимиту (XIC-29) и авто-яркости. --- ## 7. Две кривые и лимит **Кривых две** — для сети и для батареи (`AutoBrightnessPointsAc` / `…Battery`): в одних и тех же люксах у розетки хочется ярче, чем в дороге. Учится кривая того источника, который действовал в момент **начала** серии правок (правка обдумывалась при этих условиях); смена питания вызывает пересчёт по кривой нового источника. **Лимит яркости (XIC-29) — фильтр на выходе, а не часть модели**: ``` итог = min( B(lux), cap(источник) ) ``` Обучение при этом видит **нефильтрованное намерение**: поднял до 90 % при лимите 60 — кривая выучит 90. Если бы мы клампили при записи, лимит постепенно «съедал» бы настоящие предпочтения, и после его снятия кривая осталась бы изуродованной. Тот же принцип уже зафиксирован для слотов «Запоминать яркость»: **память хранит намерение, лимит фильтрует на выходе**. На графике это видно буквально: якорь может лежать выше «среза», а линия идёт по полке. ### 7.1. Обучение можно выключить (XIC-37): временные правки и возврат к выученному Тумблер «Обучение кривой» (по умолчанию вкл) решает задачу «мне сейчас нужно ярче, но не запоминай». Выключен → кривая заморожена и становится авторитетом, а ручная правка — временным отклонением. Что происходит дальше, решает комбо **«Возврат к выученному»**: - **Всегда** (по умолчанию) — мягкое схождение, механика лимита (XIC-29), но в обе стороны: 1. Правка отличается от предсказания больше мёртвой зоны → через `AutoBrightnessRevertMs` (1 мин) разрыв «правка ↔ кривая» сокращается в `BrightnessGapDivisor` раз плавным ходом, и так до схождения (80 → 70 → 65 → 63 → 62 → 60 при предсказании 60). 2. Правка **после нашего шага** (в любую сторону) — осознанный протест: уступаем на `AutoBrightnessRevertBackoffMin` (2 ч). Пауза абсолютная — не трогаем яркость даже при смене света. 3. Пауза кончается раньше по любому «смена условий»: блокировка/разблокировка сеанса, сон, смена питания. После — обычный пересчёт приводит яркость к выученному уровню. - **Только от батареи** — то же, но лишь на батарее; от сети правка живёт до смены света. - **Выключен** — шагов схождения и пауз нет вовсе: правка держится неограниченно. Оговорка (осознанное решение): «живёт до смены света» и «держится неограниченно» — про спор *в текущих условиях*. Пересчёт по кривой в любом режиме вызывают **значимая смена света** (обычный конвейер), **смена питания** (кривая другого источника) и **блокировка/разблокировка сеанса** — временная правка не переживает Win+L: вернулся к компу — экран по правилам. Кривая при этом не модифицируется вообще (в том числе незаконченная серия «раздумья» отменяется переключением тумблера). Значимая смена света закрывает эпизод схождения. --- ## 8. Датчик: что выяснилось на железе Три факта, каждый стоил отдельной пробы (TM2424, Intel Sensor Hub `HID\VID_8087&PID_0AC2`): 1. **Классический COM Sensor API в elevated-процессе мёртв.** `ISensorManager` находит датчик, подписка возвращает `S_OK`, но событий нет и `ISensor::GetData` данных не даёт. Без повышения прав всё работает. Наш exe — `requireAdministrator` всегда, поэтому канал непригоден целиком. 2. **Рабочий канал — WinRT `LightSensor`**, активированный вручную через `RoGetActivationFactory`: проекции Windows SDK добавили бы ~25 МБ к двухмегабайтному exe. IID и порядок vtable сняты с системного `Windows.Devices.winmd`; три слота `IInspectable` доложены вручную поверх `IUnknown` (режим `InterfaceIsIInspectable` рантайм .NET 5+ не поддерживает). Параметризованный IID делегата события `ITypedEventHandler` вычислен по алгоритму WinRT pinterface (UUID v5 от сигнатуры) — `{1ECF183A-9F0A-5F73-9225-5A33EAB5594F}`. 3. **Датчик стримит только при живом событийном подписчике.** Без подписки сервис усыпляет сенсор, и `GetCurrentReading` бесконечно отдаёт последний кэш — с застывшей меткой времени, одинаково для всех клиентов в системе (ловили «вечные 815 лк»). Поэтому подписка обязательна, даже когда значения снимаются опросом. Плюс наблюдение из сырого лога (~7 опросов/с, прогулка по квартире): измерения ложатся на строгую секундную сетку (наш `ReportInterval`), но **новое измерение появляется только при изменении освещённости** — на стабильном свете метка не обновляется десятки секунд. Для нашего конвейера это удобно: опрос снимает удерживаемое значение, и в медианном окне устойчивый свет честно взвешен по времени. Подписку и опрос держит **выделенный фоновый MTA-поток**, живущий до `Dispose`: инициализация из короткоживущего `Task.Run` молча глохнет — COM-квартира умирает вместе с потоком, и сервис теряет приёмник. --- ## 9. Параметры (config.json) В UI вынесены тумблер, «Инерция датчика» и кнопка сброса обучения. Остальное правится руками, применяется на следующем запуске. | Ключ | По умолчанию | Смысл | |------|-------------:|-------| | `AutoBrightness` | `false` | фича включена | | `AutoBrightnessPointsAc` / `…Battery` | дефолтная кривая | якорные точки (обучаются сами) | | `AutoBrightnessMedianSec` | `10` | окно медианы, с; `0` — без фильтра | | `AutoBrightnessHysteresis` | `0.1` | порог значимости `δ` в лог-шкале (≈ ±26 % лк); он же порог склейки R3 | | `AutoBrightnessLearnBlend` | `0.5` | доля новой правки при уточняющем обучении (`β`); `1` — как раньше, буквально | | `AutoBrightnessFineStep` | `10` | до какой разницы правка считается уточняющей (= шаг клавиш Windows) | | `AutoBrightnessSettleMs` | `2000` | дебаунс стабилизации света | | `AutoBrightnessLearnMs` | `5000` | «период раздумья» перед обучением | | `AutoBrightnessDeadband` | `5` | мёртвая зона по яркости, % | | `AutoBrightnessLearning` | `true` | обучение кривой (есть в UI); `false` — правки временные (§7.1) | | `AutoBrightnessRevert` | `null` | возврат к выученному (есть в UI): `null` — всегда, `"battery"` — только на батарее, `"off"` — не возвращать | | `AutoBrightnessRevertMs` | `60000` | интервал шагов схождения к выученному | | `AutoBrightnessRevertBackoffMin` | `120` | пауза после «пользователь настоял», мин | | `BrightnessRampMs` | `10000` | длительность плавного хода (общая с XIC-29) | | `BrightnessGapDivisor` / `BrightnessSnapPercent` | `2` / `2` | делитель разрыва и порог доводки шага (общие с XIC-29) | Сброс обучения — только явной кнопкой «Сбросить кривую обучения» (стирает обе кривые). Выключение и включение фичи кривые **не трогает**: выключил на неделю — вернулся к своим. --- ## 10. Диагностика: как увидеть, что происходит Фича намеренно наблюдаемая — три точки, где видно её состояние. **График на вкладке «Экран»** (обновляется раз в секунду): - две линии — сеть (цвет акцента) и батарея (оранжевый), обе всегда, независимо от питания; - **точки** — якоря кривой: и заводские, и выученные; после правки новый якорь появляется через «период раздумья» (5 с) — по нему видно, что обучение случилось; - **пунктирная вертикаль** — текущая освещённость, кружок на ней — какую яркость держит активная кривая; - если включён лимит, линия и точки **срезаны** по нему — видно эффективное поведение. **`%APPDATA%\XiControl\config.json`** — вся «модель» открытым текстом: `AutoBrightnessPointsAc` / `AutoBrightnessPointsBattery`, по паре `Lux`/`Percent` на якорь. Никакого скрытого состояния: что видно в файле — то и работает. **`%APPDATA%\XiControl\log.txt`** (включается тумблером «Логировать ошибки», Настройки → Общие): | Строка | Что означает | |--------|--------------| | `AutoBrightness: выучено 65% при 200 лк (сеть, точек: 7)` | правка отстоялась и стала якорем; в скобках — чья кривая и сколько в ней точек | | `AutoBrightness: кривые обучения сброшены к умолчанию` | нажата кнопка сброса | | `BrightnessCap: шаг схождения 80% → 70% (лимит 60%)` | работает лимит (XIC-29), а не авто-яркость | | `BrightnessCap: адаптивная яркость включена — лимит не работает` | стоп-фактор Windows, то же касается авто-яркости | | `AlsWatcher: подписка ReadingChanged не удалась (0x…)` | датчик остался на опросе — данные могут «замереть» (см. §8) | ## 11. Частые вопросы **Яркость не меняется вообще, хотя свет поменялся.** По порядку проверки: 1. Включена ли **адаптивная яркость Windows** — тогда фича молчит намеренно, на вкладке висит плашка с причиной; 2. **лимит** текущего источника не равен той яркости, что уже стоит (лимит 30 % и яркость 30 % — расти некуда); 3. свет **нестабилен** — если освещённость мечется, дебаунс стабилизации перезапускается и решение откладывается; это не зависание, а осознанный отказ реагировать (§5); 4. разница между предсказанием и текущей яркостью меньше **мёртвой зоны** (5 %). **Освещённость в настройках замерла на одном числе.** Признак того, что сенсорный сервис усыпил датчик и отдаёт кэш; лечится подпиской, которую приложение ставит само (§8). Если воспроизводится — смотреть в логе строку `AlsWatcher: подписка … не удалась`. **Реагирует не сразу.** Так и задумано: сначала медиана окна «инерции» (по умолчанию 10 с), потом дебаунс стабилизации (2 с), и только затем плавный ход (~10 с). Хотите резвее — уменьшите `AutoBrightnessMedianSec` (в UI) и `AutoBrightnessSettleMs`. **Выучилось что-то не то.** Кнопка «Сбросить кривую обучения» стирает обе кривые и возвращает заводскую. Точечно править якоря в `config.json` можно, но не нужно: проще «переучить» — поставить комфортную яркость при этом освещении, кривая подстроится сама. **Хочу, чтобы яркость просто не превышала N %.** Это другая фича — лимит яркости (XIC-29), она работает и без авто-яркости. ## 12. Что осталось за кадром - **Этап 2 (XIC-31)**: «белизна» содержимого экрана как вторая координата якорей — тёмная IDE ночью и белый сайт ночью суть разные условия. Требует захвата кадра (DXGI Desktop Duplication) и решения вопросов с HDR и DRM-контентом. - **Время суток** намеренно не берём: освещённость уже знает, что вечером темно, а лишняя координата ухудшает объяснимость. - **Адаптивная яркость Windows** — стоп-фактор: два регулятора одного ползунка неизбежно дают качели. Детектируется через `powrprof.dll` (`ADAPTBRIGHT` в активной схеме питания), причина честно показывается на вкладке.