# Спецификация 02: ядро — SetTracker / RestTimer / ZoneCalc Источники: module_reqs разд. 1 (единицы 2–4), Ф-2.1–Ф-2.4 (с errata Ф-2.3), решение гейта 1.3 № 1/5. Чистые классы Monkey C без UI и без обращений к Toybox.WatchUi — тестируются Toybox.Test. Общее для всех: часы ядра — `Activity.Info.timerTime` (активное время активности, мс; при паузе стоит — таймеры поля замирают сами, отдельная обработка паузы не нужна). Значения времени приходят извне (параметрами) — ядро системное время не читает. ## 1. `ZoneCalc` — зоны пульса Вход: `maxBpmFor(hrMaxBpm, age)`: - `hrMaxBpm > 0` → `hrMaxBpm` (ручной ввод); - иначе формула Танаки: `208 − 0,7 × age`, округление до целого вверх (`Math.ceil`); `age` вне 14–100 → умолчание 45. `zoneFor(bpm, maxBpm)` → зона: | Зона | Диапазон от maxBpm | Цвет (MIP) | Подпись ru / en | |---|---|---|---| | BLUE | bpm < 50 % | Graphics.COLOR_BLUE | «восстановление» / «recovery» | | GREEN | 50 % ≤ bpm < 70 % | COLOR_GREEN | «лёгкая» / «easy» | | YELLOW | 70 % ≤ bpm < 85 % | COLOR_YELLOW | «средняя» / «moderate» | | RED | 85 % ≤ bpm | COLOR_RED | «высокая» / «high» | - сравнение целочисленное без Float: `bpm*2 vs maxBpm` (50 %), `bpm*10 vs maxBpm*7` (70 %), `bpm*20 vs maxBpm*17` (85 %); - `bpm == null` или ≤ 0 → зона NONE (серый, прочерк) — «нет данных»; - подписи зон совпадают с «Профилем» телефона m10 (согласованный словарь системы). ## 2. `SetTracker` — метрики подхода (границы Lap→Lap, errata Ф-2.3) Состояние: `lapAtMs` (timerTime последнего Lap), `sumBpm`, `nBpm`. - `onLap(timerTimeMs)`: `lapAtMs = timerTimeMs; sumBpm = 0; nBpm = 0` — метрики обнуляются на границе (Ф-2.3); - `onSample(bpm)`: `bpm != null && bpm > 0` → `sumBpm += bpm; nBpm += 1`; null-пульс пропускается (в среднее не попадает); - `elapsedMs(timerTimeMs) as Long`: `timerTimeMs − lapAtMs`; - `avgBpm() as Number or Null`: `nBpm > 0` → округление `(sumBpm + nBpm/2) / nBpm`, иначе null («—» до первого сэмпла); - стартовая граница — `onTimerStart` активности (`lapAtMs` = 0): метрики считаются с начала активности до первого Lap. «Время подхода» = `elapsedMs` (включает предшествующий отдых — errata Ф-2.1); «средний пульс подхода» = `avgBpm` за тот же интервал. ## 3. `RestTimer` — автономный таймер отдыха (Ф-2.4 errata) Состояние: `active`, `restEndMs`, `fired`. - `start(timerTimeMs, restSec)`: `active = true; restEndMs = timerTimeMs + restSec*1000; fired = false`; - `remainingMs(timerTimeMs) as Long`: `active` → `restEndMs − timerTimeMs`, иначе 0; - `isResting(timerTimeMs)`: `active && remainingMs > 0` — на экране строка «ОТДЫХ mm:ss» (остаток); - `onEnd(timerTimeMs)`: `active && remainingMs ≤ 0 && !fired` → `fired = true`, вернуть `true` — сигнал на однократную лёгкую вибрацию (спека 04); повторные вызовы `false`; - пауза активности: `timerTime` стоит → остаток не меняется (отдых «заморожен», Ф-2.6 штатная пауза); отдельной логики нет; - до первого Lap таймер не активен (`active = false` при старте активности). Умолчание длительности — `FieldSettings.restSec` (120 = `Rest.DefaultSec` телефона, спека 04). ## 4. `Vibe` — паттерны и гистерезис (спека 04 — параметры) Красная зона: `onZone(zone, timerTimeMs)`: - зона RED и прошло ≥ 60 000 мс от последней двойной вибрации → сигнал двойной вибрации, фиксируется время; - выход из RED (зона GREEN/BLUE/NONE) → гистерезис сброшен — следующее входжение в RED вибрирует сразу. ## 5. Тесты (Toybox.Test, `test\`) `ZoneCalcTest`: 1. Танака: `maxBpmFor(0, 45) == 177` (208 − 31,5 = 176,5 → ceil); 2. ручной приоритет: `maxBpmFor(190, 45) == 190`; 3. возраст вне диапазона → расчёт от 45 (`maxBpmFor(0, 5) == 177`); 4. границы зон при maxBpm 180: 89 → BLUE, 90 → GREEN, 125 → GREEN, 126 → YELLOW, 152 → YELLOW, 153 → RED (50 % = 90, 70 % = 126, 85 % = 153 — целочисленные пороги); 5. `zoneFor(null, 180) == NONE`. `SetTrackerTest`: 1. среднее округление: сэмплы [100, 101, 101] → 101; 2. null-сэмплы не влияют: [100, null, 102] → 101; 3. `onLap` обнуляет: сэмплы до/после Lap — среднее только после; 4. `elapsedMs` от границы: Lap на 60 000, сейчас 75 500 → 15 500; 5. до сэмплов `avgBpm() == null`. `RestTimerTest`: 1. старт по Lap на t=60 000, restSec 120: `remainingMs(60 000)==120 000`, `remainingMs(120 000) == 60 000`, `remainingMs(180 000) == 0`; 2. `onEnd` срабатывает однократно: false при остатке > 0; true при 180 000; повторный вызов false; 3. `isResting`: true при остатке > 0, false при ≤ 0; 4. после конца отдыха `isResting == false`, но `active` до следующего Lap не перезапускается; 5. новый `start` сбрасывает `fired`. `Vibe` (гистерезис) — в тестах RestTimer/Vibe: 6. RED в t=0 → вибрация; RED в t=30 000 → нет (< 60 с); RED в t=61 000 → вибрация; 7. GREEN между — сброс: RED t=0 → вибрация; GREEN t=10 000; RED t=20 000 → вибрация (сброс сработал).