Перейти к содержанию

6 Программная обвязка обучения

Главы 4 и 5 описывают платформу со стороны runtime-контуров — Unity, backend, Web UI и плагинная подложка. Настоящая глава смотрит на ту же платформу со стороны исследователя машинного обучения: разбирает Python-обвязку, которая превращает Unity-симулятор в источник наблюдений для Stable-Baselines3, оркестрирует параллельный запуск, выполняет PPO, сохраняет артефакты и возвращает обученную ONNX-модель в backend через тот же контракт, что и операторский интерфейс. Изложение опирается на исходники из python/sim_client/ и python/training/; цитируемые имена существуют в репозитории на момент написания работы.

Глава описывает программную обвязку обучения, а не результаты sim-to-real-переноса — последние выделены в главу 7 настоящей работы. Когда речь заходит о наблюдаемых проблемах сходимости при тяжёлой доменной рандомизации, материал ограничивается описанием реализации и фиксацией факта: подбор архитектуры и гиперпараметров находится в исследовательской фазе.

6.1 Связь Python-контура с симулятором

6.1.1 SimClient как единственная точка получения наблюдений

Все взаимодействия Python-кода с Unity-runtime проходят через один класс — SimClient (python/sim_client/http_client.py). Это сознательное архитектурное решение: для всей тренировочной обвязки Unity существует исключительно как HTTP-сервер на http://127.0.0.1:8000, отвечающий по контракту runtime API, описанному в главе 4 диссертации. Никакая часть тренировочного кода не импортирует Unity-сборку напрямую, не разделяет с ней процессное пространство и не использует общую память — связь идёт только через сетевые запросы.

class SimClient:
    def __init__(self, base_url: str, timeout_s: float = 10.0):
        self.base_url = base_url
        self.timeout_s = timeout_s
        self.session = requests.Session()
        adapter = HTTPAdapter(pool_connections=4, pool_maxsize=16, max_retries=0)
        self.session.mount("http://", adapter)
        self.session.mount("https://", adapter)

    def health(self) -> Dict[str, Any]: ...
    def get_contract(self) -> Dict[str, Any]: ...
    def reset(self, config: Dict[str, Any]) -> Dict[str, Any]: ...
    def step(self, command: Dict[str, Any]) -> Dict[str, Any]: ...

Использование requests.Session с явно сконфигурированным HTTPAdapter решает проблему, выявленную при ранних запусках на Windows. В Python без сессии каждый HTTP-запрос порождает новое TCP-соединение; при тренировке со скоростью порядка сотни шагов в секунду на четыре параллельных Unity-инстанции пул эфемерных портов исчерпывается за несколько минут (TIME_WAIT накапливается быстрее, чем освобождается), и SubprocVecEnv-воркеры начинают падать с WinError 10055 или EOFError. Постоянная сессия и keep-alive снимают проблему. Параметр pool_maxsize=16 подобран эмпирически — потолок одновременных вызовов от одного Python-процесса к одному Unity-серверу при N агентах в MultiAgentVisionVecEnv.

Класс намеренно содержит минимальный набор методов и не вводит уровней абстракции поверх HTTP. Семантические интерпретации — поля наблюдения, поля команды, форматы JPEG-кадров, идентификаторы агентов — остаются на стороне environment-классов из python/training/. Такое разделение позволяет переиспользовать клиент в трёх контекстах: тренировочном цикле, KPI-оценке evaluate_ab_policy.py и в инструментах rusim doctor/rusim contract.

Помимо тренировочных endpoint-ов клиент содержит методы для работы с реестром моделей backend: list_models, get_active_model, activate_model, set_model_binding, upload_model. Эти методы используются на этапе доставки артефакта; размещение их в том же классе оправдано тем, что инструменты rusim model install и rusim model activate через тот же SimClient обращаются к backend-API.

6.1.2 Семантика reset и step

Контракт между Python-контуром и Unity сводится к двум методам: reset и step. Семантика этих методов на стороне Unity описана в главе 3 («SimulationManager: ядро жизненного цикла»); здесь рассматривается, как тренировочная обвязка с ними работает.

reset(config) принимает структуру SimulationConfig в JSON-форме: идентификаторы трассы и транспортного средства, параметры трассы, параметры агента, флаги и seed. Метод синхронен — Python-вызов блокируется, пока Unity не завершит уничтожение прошлой сцены, инстанцирование новой, применение параметров и первый snapshot. Возвращается полный StepResult, который содержит начальное наблюдение для policy и пустой info (на стороне Unity это вырожденный шаг с командой (0, 0)). Тренировочный код использует возвращаемое значение reset непосредственно — никакой дополнительной фазы прогрева нет.

step(command) принимает ControlCommand — пару (throttle, steer) для непрерывных моделей, либо одно из дискретных значений DirStop/Forward/Back/Left/Right, переданное через wrapper. Возвращаемый StepResult содержит поле state.camera.frame (base64-кодированный JPEG камеры primary-агента), массив telemetry, поля state.pose (позиция и ориентация Rigidbody) и info со служебными ключами (frame_id, agent_count, route.completed, route.distance_m).

def reset(self, *, seed=None, options=None):
    step = self.client.reset(self._reset_config)
    obs, _ = self._build_observation(step)
    self._steps = 0
    self._last_progress = 0.0
    return obs, {}

def step(self, action):
    cmd = {"throttle": float(action[0]), "steer": float(action[1])}
    step = self.client.step(cmd)
    obs, info = self._build_observation(step)
    reward, terminated, truncated = self._compute_reward(step, action)
    self._steps += 1
    return obs, reward, terminated, truncated, info

Принципиальное свойство этой пары — синхронность. Тренировочный цикл PPO работает в стандартном Gymnasium-режиме: «получили obs, посчитали действие, отправили action, дождались следующего obs». Никаких асинхронных колбэков, очередей телеметрии или потоковой подписки на стороне Python нет. Все хитрости параллелизма вынесены в MultiAgentVisionVecEnv и MetaMultiAgentVecEnv, которые работают над тем же синхронным контрактом, отправляя в одну итерацию пакеты команд для нескольких агентов через явный targetAgentId.

6.1.3 Загрузка сценария и параметризация запуска

Источником SimulationConfig для всех тренировочных запусков служит файл сценария в формате YAML. Сценарии размещены в configs/scenarios/; типичный пример — cardboard-corridor-v1.yaml, описывающий узкий коридор шириной 0.6 м, KS0223-агента и waypoint-маршрут «A → B». Загрузку и валидацию сценариев выполняет модуль python/sim_client/scenario.py.

def load_scenario_file(path: str | Path) -> Dict[str, Any]:
    file_path = Path(path)
    suffix = file_path.suffix.lower()
    raw = file_path.read_text(encoding="utf-8")
    if suffix == ".json":
        payload = json.loads(raw)
    elif suffix in {".yaml", ".yml"}:
        if yaml is None:
            raise RuntimeError("PyYAML is required to load YAML scenarios.")
        payload = yaml.safe_load(raw)
    else:
        raise ValueError(f"Unsupported scenario file extension: {suffix}")
    if not isinstance(payload, dict):
        raise ValueError("Scenario document must be a JSON/YAML object.")
    return payload

Функция validate_scenario дополнительно проверяет согласованность полей: наличие runtime.runtimeMode, ключа world.trackId, типов поля vehicle.vehicleId, корректности списка route.waypoints, согласованности счётчика agents.count с массивом agents.vehicles. Валидация происходит на стороне Python до отправки конфигурации в Unity — это позволяет получить осмысленную диагностику с указанием конкретного отсутствующего поля.

Преобразование загруженного сценария в SimulationConfig-payload выполняет scenario_to_reset_config. Значения по умолчанию для time_scale, max_steps, oob_margin_m берутся из CLI-аргументов тренировочного скрипта; параметры маршрута и геометрии трассы — из секций route.params и route.waypoints. Сценарий служит «паспортом эксперимента»: пара (commit, scenario file) однозначно описывает, в какой среде шло обучение. Этот паспорт сохраняется в metadata.json рядом с моделью под ключом scenario.

Помимо сценариев тренировочный пайплайн принимает параметры через CLI трёх категорий. Первая — параметры запуска Unity (адрес, порт, число параллельных инстанций). Вторая — параметры обучения (число шагов, гиперпараметры PPO, опции wrapper-стека). Третья — параметры доменной рандомизации (spawn-jitter-m, ultrasonic-noise-sigma, lateral-penalty-mult); они частично попадают в Unity через trackParams и частично остаются в env-обёртках. В metadata.json зафиксированы все три категории, что обеспечивает воспроизводимость.

6.2 Обёртки сред: единый VecEnv-интерфейс

Stable-Baselines3 ожидает на входе либо обычную gym.Env, либо VecEnv, обслуживающий несколько параллельных эпизодов с единым observation/action space. В Python-контуре платформы реализованы четыре варианта среды, эквивалентные с точки зрения PPO, но различающиеся по тому, как именно они получают наблюдения и куда отправляют команды.

flowchart LR
    PPO[Stable-Baselines3 PPO]
    VecEnv[(VecEnv API)]

    Single[ABCorridorVisionEnv\n1 агент, 1 Unity]
    Multi[MultiAgentVisionVecEnv\nN агентов, 1 Unity]
    Meta[MetaMultiAgentVecEnv\nM Unity x N агентов]
    Genesis[CorridorGenesisVecEnv\nN агентов, GPU]

    Wrappers[DiscreteAction → Latency → AntiSpin → ImageAug]

    PPO --> VecEnv
    VecEnv --> Single
    VecEnv --> Multi
    VecEnv --> Meta
    VecEnv --> Genesis

    Single --> Wrappers
    Multi --> Wrappers
    Genesis --> Wrappers

    Single -->|HTTP step/reset| Unity1[(Unity 1)]
    Multi -->|HTTP step/reset| Unity1
    Meta -->|N портов| UnityN[(Unity 1..M)]
    Genesis -->|in-process| GPU[(Genesis GPU)]

Рисунок 6.1 — Варианты сред и их связь с тренировочным циклом

Все четыре варианта возвращают одно и то же observation_space: словарь с ключами image (84×84×3 uint8 RGB) и ultrasonic (одномерный float32 в диапазоне 0..1). Это позволяет тренировать одну и ту же CNN-policy под любой из бэкендов без модификации кода policy.

6.2.1 Одиночная среда (ABCorridorVisionEnv)

ABCorridorVisionEnv (python/training/ab_corridor_vision_env.py, 836 строк) — базовый класс, описывающий одиночного агента в одиночной Unity-инстанции. Это gym.Env, не VecEnv: каждый его шаг — один HTTP step к одному Unity-серверу. Предполагается, что Unity запущен оператором отдельно (./rusim server up --count 1), а среда подключается к существующему серверу по base_url.

class ABCorridorVisionEnv(gym.Env):
    def __init__(self, base_url="http://127.0.0.1:8000",
                 scenario_path=None, max_steps=600,
                 corridor_width_m=None, oob_margin_m=0.3,
                 goal_radius_m=None, time_scale=2.0,
                 grayscale=False, img_size=84,
                 track_id="track.basic_arena.v1",
                 vehicle_id="vehicle.prometeo.sport.v1",
                 ...):
        self.client = SimClient(base_url, timeout_s=30.0)
        self.observation_space = spaces.Dict({
            "image": spaces.Box(0, 255, (img_size, img_size, 3), dtype=np.uint8),
            "ultrasonic": spaces.Box(0.0, 1.0, (1,), dtype=np.float32),
        })
        self.action_space = spaces.Box(-1.0, 1.0, (2,), dtype=np.float32)

Среда отвечает за три функции, не покрываемые Unity-runtime: вычисление reward, проекция позиции агента на маршрут (для метрики progress) и формирование observation из base64-кадра камеры (декодирование, ресайз). Функция _compute_reward собирает суммарную награду из компонентов progress, lateral_penalty, heading_alignment_bonus, survival, time, backward_penalty, goal_bonus, oob_penalty, stall_penalty. Каждый компонент возвращается в info["reward_breakdown"] для логирования через RewardBreakdownCallback. Эта структура нужна для отладки: разложение по компонентам показывает, какой именно член склоняет policy в нежелательное поведение, тогда как суммарная награда такого ответа не даёт.

Геометрия маршрута реконструируется из route.waypoints сценария. На каждом шаге вычисляются ближайшая точка на ломаной (_seg_dist), параметр проекции (_project_t) и накопленный прогресс (_route_progress). Геометрические функции вынесены в отдельные _* помощники, что позволяет переиспользовать их в MultiAgentVisionVecEnv и в evaluate_ab_policy.py без дублирования.

6.2.2 Multi-agent в одной Unity-инстанции (MultiAgentVisionVecEnv)

Базовый класс ABCorridorVisionEnv обслуживает один агент. Параллельное обучение через SubprocVecEnv SB3 требует N отдельных Python-процессов, каждый из которых поднимает собственный SimClient к собственной Unity-инстанции — N инстанций Unity со своими физическими движками, своими камерами и своими серверами. Это даёт честный параллелизм, но обходится в 5-7 ГБ оперативной памяти на инстанцию и значительный CPU-overhead.

MultiAgentVisionVecEnv (python/training/multi_agent_vision_env.py, 564 строки) реализует альтернативную модель: одна Unity-инстанция содержит N агентов в одной сцене (через SimulationConfig.agents), и Python-контур обращается к ним по очереди в одной HTTP-сессии. Это срабатывает потому, что Unity физически считает все N агентов одним FixedUpdate-циклом — суммарная стоимость на сцене в N раз меньше, чем у N независимых инстанций.

class MultiAgentVisionVecEnv(VecEnv):
    def __init__(self, n_agents, base_url, scenario_path,
                 max_steps, time_scale, img_size,
                 corridor_width_m, goal_radius_m,
                 waypoints, track_id="track.cardboard_corridor.v1",
                 maze_randomize=False, ...):
        self.n_agents = int(n_agents)
        self.client = SimClient(base_url, timeout_s=30.0)
        observation_space = spaces.Dict({...})
        action_space = spaces.Box(-1.0, 1.0, (2,), dtype=np.float32)
        super().__init__(self.n_agents, observation_space, action_space)

Принципиальный момент — поведение при терминации эпизода одного из агентов. SB3 ожидает, что VecEnv поддерживает auto-reset: при done=True для агента i среда формирует terminal_observation в info[i]["terminal_observation"], а затем посылает в Unity reset для этого агента с сохранением остальных. Соответствующий вызов в Unity-runtime — reset_agent(agent_id, vehicle_params, spawn_params) — реализован в SimulationManager ровно для этого случая.

Параметры maze_randomize и maze_regen_every предназначены для трассы track.cardboard_maze.v1, поддерживающей процедурную генерацию лабиринта при reset. При их включении MultiAgentVisionVecEnv передаёт набор trackParams (maze.seed, maze.length_cells, maze.left_turns, maze.right_turns, maze.corridor_width_m, maze.wall_height_m), которые Unity-runtime считывает в CardboardMazeTrack.ResetTrack.

6.2.3 Meta-multi-agent: N Unity на M агентов (MetaMultiAgentVecEnv)

MultiAgentVisionVecEnv упирается в потолок производительности одиночной Unity-инстанции: при достижении примерно восьми агентов в одной сцене стоимость рендеринга восьми отдельных камерных текстур начинает доминировать над выгодой от единого FixedUpdate. Дополнительно при некоторых настройках Unity-сцены наблюдается сериализация камерных проходов, и время одного шага растёт нелинейно.

MetaMultiAgentVecEnv (python/training/meta_multi_agent_vec_env.py) обходит этот потолок, объединяя несколько MultiAgentVisionVecEnv поверх отдельных Unity-процессов на разных портах.

class MetaMultiAgentVecEnv(VecEnv):
    def __init__(self, n_unity, agents_per_unity, base_url_template, ...):
        self.inner_envs: list[MultiAgentVisionVecEnv] = []
        for i in range(self.n_unity):
            url = base_url_template.format(port=base_port + i)
            env = MultiAgentVisionVecEnv(
                n_agents=self.agents_per_unity, base_url=url, ...)
            self.inner_envs.append(env)
        total_agents = self.n_unity * self.agents_per_unity
        self._executor = ThreadPoolExecutor(max_workers=self.n_unity)
        super().__init__(total_agents, ...)

Ключевая деталь — пул потоков: вызовы inner.step для разных Unity-инстанций отправляются параллельно через ThreadPoolExecutor, а не последовательно. Поскольку каждый вызов блокируется в socket.recv на ответе Unity-runtime, Python GIL во время блокировки освобождён, и параллельный execution случается на уровне ОС — N сетевых ожиданий протекают одновременно. Это позволяет получить почти линейный прирост производительности по числу Unity-инстанций без перехода на multiprocessing.

Меta-VecEnv введён как обход проблемы: на Windows с Python 3.13 SubprocVecEnv детерминированно падал примерно на 116-тысячном шаге с разрывом pipe. Воспроизводимость отказа подтверждалась многократно. Многопоточная meta-VecEnv-обёртка обходит её: pipe не используется, обмен идёт по TCP-сокетам, переиспользуемым через requests.Session.

6.2.4 Genesis-вариант для GPU-параллельной симуляции

Все три рассмотренные среды используют Unity как симулятор и упираются в его пропускную способность: порядка двухсот шагов в секунду на одну инстанцию, до 1500-1800 в meta-multi-agent с тремя Unity и восьмью агентами. Этого достаточно для исследовательских прогонов, но недостаточно для серьёзных sweep-ов гиперпараметров.

Четвёртый вариант — CorridorGenesisVecEnv (python/training/corridor_genesis_env.py, 419 строк) — реализован поверх Genesis, GPU-параллельного физического симулятора. Genesis запускает N сред в одном процессе на GPU, что снимает HTTP-overhead и параллелизирует физику и рендеринг на уровне CUDA-ядер. Целевая пропускная способность на RTX 5080 — порядка пяти тысяч шагов в секунду при n_envs=512.

class CorridorGenesisVecEnv(VecEnv):
    def __init__(self, n_envs=64, max_steps=400,
                 corridor_width_m=0.60, oob_margin_m=0.10,
                 goal_radius_m=0.25, waypoints=None,
                 img_size=84, env_spacing=(12.0, 12.0),
                 backend="auto", dt=DEFAULT_DT, show_viewer=False):
        ...

KS0223 в Genesis-варианте моделируется не как полная физическая модель с колёсами, а как кинематический box, у которого напрямую задаются линейная и угловая скорости. Реальный KS0223 имеет дифференциальный привод, его динамика близка к идеальному кинематическому управлению, поэтому потеря точности компенсируется выигрышем в скорости. Калибровка — ROBOT_MAX_SPEED = 0.73 м/с, ROBOT_MAX_YAW_RATE = 380 °/с, DEFAULT_DT = 0.14 с — взята из натурных замеров (глава 7). Структура reward повторяет ABCorridorVisionEnv._compute_reward: те же компоненты с теми же весами. На уровне обвязки Genesis-бэкенд взаимозаменяем с Unity-бэкендом.

6.2.5 Wrappers: DiscreteActionWrapper, image augmentation, latency

Над любой из четырёх сред поднимается стек обёрток, превращающий её непрерывный action-space в дискретный, добавляющий action latency и применяющий аугментации к наблюдениям. Стек применяется в строго заданном порядке, который реализован в функции _wrap_env тренировочного скрипта.

def _wrap_env(base_env, *, enable_aug, enable_anti_spin,
              enable_latency, latency_steps, seed,
              enable_discrete=True, strong_aug=False, latency_max=-1):
    env = base_env
    if enable_discrete:
        env = DiscreteActionWrapper(env)
        if enable_latency and latency_steps > 0:
            dmax = latency_max if latency_max > latency_steps else None
            env = DelayedActionWrapper(env, delay_steps=latency_steps, delay_max=dmax)
        if enable_anti_spin:
            env = AntiSpinRewardWrapper(env)
    if enable_aug:
        env = ImageAugObservationWrapper(env, enable=True, seed=seed)
    return env

DiscreteActionWrapper (python/training/discrete_action_wrapper.py) превращает Box(-1, 1, (2,)) в Discrete(5). Каждое из пяти действий маппится в фиксированную пару (throttle, steer): DirStop=(0,0), DirForward=(+1,0), DirBack=(-1,0), DirLeft=(0,+1), DirRight=(0,-1). Это сделано ради соответствия реальному KS0223: робот через MainControl.py принимает только пять дискретных команд, и обучать policy в континуальном action space — значит на стороне backend всё равно проецировать выход на дискретное множество, теряя плавность управления, которой policy научили в симе.

Тест test_discrete_action_wrapper.py фиксирует, что вектор действия совпадает с указанной таблицей. В ранней ревизии DirLeft имел форму (+0.5, +1.0) («forward+turn arc»), позже выяснилось, что Ks0223Vehicle в Unity реализует чистый дифференциальный привод, и (0, +1) корректно трактуется как поворот на месте.

DelayedActionWrapper (python/training/latency_wrapper.py) добавляет лаг между подачей действия и его применением. Реальный inference loop на KS0223 работает с интервалом порядка 140 мс (7 Гц), и за этот интервал среда меняется. Без латентности policy учится по «свежим» кадрам, а на реальном роботе её действие применяется к состоянию, наступившему через тик. Wrapper хранит очередь длиной delay_steps. Опциональный delay_max включает рандомизацию задержки в [delay_steps, delay_max], моделируя jitter USB- и сетевых задержек.

ImageAugObservationWrapper (python/training/image_aug_wrapper.py) применяет к obs["image"] стохастический набор аугментаций: яркость, контраст, hue-shift, размытие, JPEG-recompression, гауссов шум. Режим --strong-aug усиливает диапазоны (0.30 вместо 0.15, blur radius до 2.0). Цель — подготовить policy к различиям между чистыми Unity-кадрами и зашумлёнными кадрами реальной USB-камеры.

AntiSpinRewardWrapper (python/training/anti_spin_reward.py) добавляет штраф за повторение одного и того же действия. На ранних этапах обучения PPO иногда сходился к degenerate-policy, повторявшей DirLeft бесконечно — крутящееся на месте поведение давало небольшой положительный reward от survival, но не приносило прогресса. Штраф за пять и более одинаковых действий подряд делает такое поведение невыгодным.

6.3 Тренировочный пайплайн на Stable-Baselines3

6.3.1 PPO как основной алгоритм

В качестве основного RL-алгоритма выбран PPO (Proximal Policy Optimization) в реализации Stable-Baselines3. Выбор обоснован тремя соображениями. Первое — устойчивость к гиперпараметрам: в отличие от off-policy методов вроде SAC или TD3, PPO предполагает on-policy буфер, не накапливающий stale-данные и не требующий тонкой настройки таргет-сетей и priority replay. Второе — наличие реализации MultiInputPolicy, поддерживающей обсервации в виде словаря ({image, ultrasonic}). Третье — поддержка RecurrentPPO через sb3-contrib: переход на LSTM-policy для non-Markovian задач (см. 6.4.2) делается одной CLI-опцией.

Альтернативой рассматривался DQN. От него отказались из-за того, что для CNN-policy с словарным observation space потребовалась бы адаптация QNetwork, тогда как PPO работал «из коробки». Оба алгоритма проверены на ранних спайках (rev1-rev4); PPO давал более стабильную кривую обучения на тех же 200 тысячах шагов.

Дискретное действие на стороне PPO превращается в Categorical(5)-распределение поверх логитов размерности 5, выходящих из action_net policy. Этот факт явно проверяется ассертом в тренировочном скрипте, чтобы исключить ситуацию, когда MultiInputPolicy ошибочно соберёт DiagGaussianDistribution:

print(f"  Action dist: {type(model.policy.action_dist).__name__}")
assert "Categorical" in type(model.policy.action_dist).__name__, \
    f"Expected Categorical action dist for Discrete action_space, got {type(model.policy.action_dist)}"

6.3.2 train_cardboard_corridor_v9.py: общий поток

Главный тренировочный скрипт — python/training/train_cardboard_corridor_v9.py (978 строк). «v9» в названии указывает на ревизию набора wrapper-ов; v6-v8 сохранены как train_cardboard_corridor.py для воспроизводимости старых результатов.

Поток исполнения идёт в одной функции main и состоит из шести фаз: парсинг CLI и подготовка путей; построение тренировочной среды (выбор одной из четырёх реализаций по флагам --meta-multi-agent, --multi-agent, --num-envs); оборачивание в VecFrameStack и VecNormalize; построение PPO (новый экземпляр или PPO.load при --resume); сборка callback-ов и model.learn; сохранение SB3-чекпоинта, ONNX-экспорт, запись metadata.json.

def main() -> int:
    args = parse_args()
    output_dir = resolve_artifact_dir(ROOT, args.output_dir,
                                      args.model_name, args.model_version)
    log_dir = Path(args.log_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    log_dir.mkdir(parents=True, exist_ok=True)
    ...
    model = ppo_cls(policy_id, train_env,
                   learning_rate=args.learning_rate,
                   n_steps=args.n_steps, batch_size=args.batch_size,
                   n_epochs=args.n_epochs, gamma=args.gamma,
                   clip_range=args.clip_range, ent_coef=ent_coef_value,
                   target_kl=target_kl, verbose=1, seed=args.seed,
                   device=args.device, tensorboard_log=tensorboard_log,
                   policy_kwargs=policy_kwargs)
    ...
    model.learn(total_timesteps=args.total_timesteps,
                callback=callbacks, progress_bar=False)
    model.save(str(model_path))
    export_to_onnx_discrete(model, onnx_path, img_size=args.img_size)
    write_json(output_dir / "metadata.json", metadata)

Флаг --resume предусматривает продолжение обучения с прошлого чекпоинта с обновлёнными гиперпараметрами. PPO.load восстанавливает policy и optimizer из .zip, а поверх ставятся learning_rate, clip_range, ent_coef и target_kl из новых CLI-аргументов. num_timesteps сохраняется автоматически, что позволяет не сбрасывать total-счётчик и сохранять прогресс curriculum-стейджей.

6.3.3 Гиперпараметры и их обоснование

Дефолты гиперпараметров выбраны по итогам последовательности ревизий rev1-rev42, зафиксированных в python/training/artifacts/cardboard-corridor-ppo-v9-rev*. Каждый rev-ID соответствует одному прогону с фиксированной комбинацией параметров; их изменение явно прописано в commit message и в комментариях parse_args. Текущие defaults и их назначение приведены в таблице 6.1.

Таблица 6.1 — Гиперпараметры PPO в train_cardboard_corridor_v9.py

Параметр Default Назначение Когда менять
learning_rate 3e-4 Стандартный SB3 PPO LR На R3M-extractor: понижают до 1e-4
n_steps 512 Длина rollout-а rev38: подняли с 256 — мало transitions/update на vision RL
batch_size 64 Mini-batch GAE На GPU с большой памятью повышают до 256
n_epochs 10 PPO update epochs rev38: подняли с 4 — стандарт для vision RL
gamma 0.99 Discount factor Длинные эпизоды (>400 шагов) на 0.995
clip_range 0.2 PPO clip ratio Не трогать без оснований
ent_coef 0.1 Entropy bonus rev29: явно зафиксирован 0.1 после drift на 0.02
target_kl 0.02 Anti-collapse early stop rev30: введён, по умолчанию активен
frame_stack 1 Stacking k frames rev38: рекомендуется 4 для maze
seed 42 RNG seed Sweep-ы: 42, 1337, 7, 11, 23

ent_coef = 0.1 зафиксирован после неприятного открытия: ревизии rev24-rev28 наследовали значение 0.02 от ранних экспериментов (rev10-rev18 обучались с 0.1), что в пять раз снижало entropy-бонус и приводило к раннему схлопыванию policy в degenerate-режим. Default явно поднят в коммите 4c46ef8 с комментарием в коде. Hyperparameter-инварианты дублируются в metadata.json под ключом hyperparameters.

target_kl = 0.02 — anti-collapse-значение из литературы по PPO; SB3 досрочно прерывает update, если KL-divergence между старой и новой policy превысила порог. До rev30 параметр был отключён, что в комбинации с большими ent_coef иногда давало catastrophic update.

n_steps = 512 и n_epochs = 10 подняты в rev38 после анализа литературы по vision RL. До rev37 значения были 256 и 4, что давало 1024 transitions per update при четырёх агентах — недостаточно для CNN-policy. Подъём до 4096 transitions per update (512 × 8) выровнял условия с эталонными конфигами.

Параметр --ent-coef-schedule linear включает линейную интерполяцию ent_coef через callback EntCoefScheduleCallback. SB3 нативно умеет планировать только learning_rate. Высокий entropy в начале предотвращает раннее схлопывание, низкий в конце даёт sharp-policy.

6.3.4 Курикулум и мониторинг через EvalCallback

Тренировочный цикл оснащён четырьмя стандартными callback-ами (ProgressCallback, CheckpointCallback, EvalCallback, MazeCurriculumCallback) и двумя авторскими (ActionStatsCallback, RewardBreakdownCallback).

ActionStatsCallback ведёт скользящее окно последних N действий и публикует доли пяти дискретных действий в TensorBoard как scalar-метрики actions/frac_DirStop, actions/frac_DirForward, …, actions/top_frac. Цель — раннее обнаружение degenerate-collapse: если за первые 50 тысяч шагов 95% действий — это DirRight, тренировку имеет смысл прервать вручную.

RewardBreakdownCallback агрегирует компоненты reward, возвращаемые env-обёртками в info["reward_breakdown"]. Каждые --reward-log-freq шагов средние значения (progress_mean, lateral_penalty_mean, goal_bonus_mean) публикуются в TensorBoard, что позволяет диагностировать причину застоя без перезапуска тренировки.

MazeCurriculumCallback (python/training/maze_curriculum.py) реализует staged-difficulty для процедурного maze-track-а. Конфигурация по умолчанию состоит из четырёх стадий: stage-A-easy (5 клеток, 1 поворот направо), stage-B-medium (5-7 клеток, до одного поворота налево, 1-2 направо), stage-C-hard (6-8 клеток, 1-2 налево, 1-3 направо) и stage-D-full (6-10 клеток, 1-3 в обе стороны). Стадии переключаются по total timesteps: 0, 25 000, 60 000, 100 000.

DEFAULT_STAGES: List[CurriculumStage] = [
    CurriculumStage(name="stage-A-easy", start_step=0,
                    ranges={"length_cells": (5, 5), "left_turns": (0, 0),
                            "right_turns": (1, 1),
                            "corridor_width_m": (0.58, 0.62), ...}),
    CurriculumStage(name="stage-B-medium", start_step=25_000, ranges={...}),
    CurriculumStage(name="stage-C-hard", start_step=60_000, ranges={...}),
    CurriculumStage(name="stage-D-full", start_step=100_000, ranges={...}),
]

Пороговые значения выбраны эмпирически: на rev2-rev5 наивная полнодиапазонная maze-рандомизация с первого шага не сходилась — policy не получала ни одного успешного эпизода и optimizer не имел сигнала. stage-A-easy гарантирует положительный reward на первой попытке. Дальнейшие стадии расширяют распределение по одному параметру за раз — стандартный приём curriculum learning (Bengio, 2009).

EvalCallback использует отдельную Unity-инстанцию на отдельном порту (./rusim server up --count 4 плюс --eval-base-url http://127.0.0.1:8003). Среда оценки строится _build_eval_env с отключёнными аугментациями и anti-spin-штрафом — eval должен идти на «чистом» дистрибуции. latency оставлен включённым как часть симулируемой реальности, а не рандомизации.

6.4 Domain randomization и подготовка к sim-to-real

6.4.1 Heavy-DR: текстуры, освещение, спавны

Доменная рандомизация (DR) — техника, при которой тренировочная среда генерируется со случайными вариациями визуальных и физических параметров, чтобы policy училась на распределении, более широком, чем реальная среда развёртывания. В Python-обвязке DR разделена на «лёгкую» (image augmentation, ultrasonic noise, action latency в env-обёртках) и «тяжёлую» (heavy-DR), реализуемую на стороне Unity через trackParams и vehicleParams.

Heavy-DR охватывает четыре класса вариаций: геометрия лабиринта (maze.seed, maze.length_cells, maze.left_turns, maze.right_turns, maze.corridor_width_m, maze.wall_height_m); визуальное оформление (текстуры стен, цвет освещения, интенсивность ambient, угол солнца); спавн (spawn.jitter_xy_m, spawn.jitter_yaw_deg); зашумление сенсоров (sensor.ultrasonic.noise_sigma, sensor.ultrasonic.dropout_prob).

p.add_argument("--spawn-jitter-m", type=float, default=0.0,
               help="±N metres XY spawn jitter (Unity-side via trackParams)")
p.add_argument("--spawn-yaw-jitter-deg", type=float, default=0.0,
               help="±N degrees spawn yaw jitter (Unity-side via trackParams)")
p.add_argument("--ultrasonic-noise-sigma", type=float, default=0.0,
               help="Gaussian noise stddev (meters) added to front ultrasonic")
p.add_argument("--ultrasonic-dropout-prob", type=float, default=0.0,
               help="Per-step probability ultrasonic returns 0 or 5m")

Дефолты для всех heavy-DR-параметров — нули. Включение каждого требует эмпирической проверки, что добавленный шум не уничтожает сходимость. Рекомендуемые значения для rev39+ — spawn-jitter-m=0.10, spawn-yaw-jitter-deg=30, ultrasonic-noise-sigma=0.02, ultrasonic-dropout-prob=0.02.

При исследовании sim-to-real-переноса наблюдалось систематическое схлопывание PPO под heavy-DR. В rev30-rev35 шесть последовательных попыток на разных seed-ах дали 0% success rate на eval-среде. Env-revert bisect подтвердил, что регрессия не вызвана багом в env-обёртках — те же seed-ы на rev16-rev18 без heavy-DR давали порядка 60% success. Вывод: проблема в дисперсии PPO под широким DR-распределением. Отсюда два направления (6.4.2 и 6.4.3): расширение архитектуры (frame stacking, R3M, RecurrentPPO) и более узкие визуальные изменения. Reward-шейпинг как лекарство отвергнут — изменение весов компонентов не разрешает фундаментальной проблемы.

6.4.2 Frame stacking и историческая память

Стандартное наблюдение — один кадр 84×84×3. На детерминированном коридоре Markov-предположение приближённо выполняется. На задаче навигации в случайном лабиринте (track.cardboard_maze.v1) Markov-предположение нарушается: policy, оказавшись на пересечении коридоров, не может определить из единственного кадра, по какому ответвлению уже двигалась.

Решение в виде frame stacking: к текущему кадру конкатенируются k предыдущих по канальной оси, и policy получает на вход тензор (84, 84, 3·k). Схема стандартна для Atari-RL (Mnih et al., 2015); в Python-обвязке реализована через VecFrameStack SB3.

if args.frame_stack > 1:
    if not isinstance(train_env, _VecEnvType):
        train_env = DummyVecEnv([lambda: train_env])
    train_env = VecFrameStack(train_env, n_stack=args.frame_stack,
                              channels_order='last')

Параметр channels_order='last' важен: NatureCNN ожидает канальной размерности на последнем месте, а frame_stack по умолчанию в старых версиях SB3 вставлял каналы в начало. Явное указание избавляет от источника ошибок.

Альтернативный механизм — RecurrentPPO с LSTM-policy, включаемый флагом --recurrent. Он переключает ppo_cls на sb3_contrib.RecurrentPPO и policy_id на MultiInputLstmPolicy; LSTM hidden state переносится между шагами эпизода и обнуляется при reset. Этот вариант мощнее frame stacking теоретически, но на практике сложнее в обучении и чаще требует ручной настройки n_steps и batch_size. В rev40+ оба варианта пробовались.

6.4.3 Real-camera post-processing

Флаг --real-cam-postprocess включает специализированный пайплайн пост-обработки 84×84-кадра, имитирующий характеристики реальной USB-камеры KS0223. В отличие от обычной image augmentation он не рандомизирует, а применяет фиксированную последовательность операций, выведенную из натурных съёмок реальной камеры: затемнение, десатурацию (теплота ламп), JPEG-recompression на низкое качество (артефакты USB-кодека).

Цель — не расширить распределение, а сдвинуть его центр в сторону реального. При обычной image augmentation policy учится на распределении, центрированном в Unity-render с шумовой оболочкой; при real-cam-postprocess центр смещён ближе к реальной системе. Опции совместимы: при включении обеих сначала применяется пост-обработка, затем стохастические аугментации. Часть параметров (ауто-экспозиция) выставляется на стороне Unity через vehicleParams, поэтому конструктор ABCorridorVisionEnv(real_cam_postprocess=...) пробрасывает флаг и в Unity, и в собственный пост-обрабатывающий шаг.

6.5 KPI-оценка и evidence

6.5.1 evaluate_ab_policy.py: 20-эпизодный success rate

Финальная оценка модели вынесена в отдельный скрипт python/training/evaluate_ab_policy.py (524 строки). Он не зависит от Stable-Baselines3: загружает ONNX через onnxruntime, подключается к Unity через SimClient, выполняет фиксированное число эпизодов (по умолчанию двадцать) и сохраняет KPI в JSON и SVG.

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(...)
    parser.add_argument("--base-url", default="http://127.0.0.1:8000")
    parser.add_argument("--scenario", default=str(ROOT / "configs/scenarios/ab-corridor-v1.yaml"))
    parser.add_argument("--model", default=str(ROOT / "python/training/artifacts/.../ab_corridor_policy_v1.onnx"))
    parser.add_argument("--episodes", type=int, default=20)
    parser.add_argument("--max-steps", type=int, default=220)
    parser.add_argument("--seed-offset", type=int, default=0)
    parser.add_argument("--target-agent-id", default="ego")
    parser.add_argument("--include-trajectories", action="store_true")
    parser.add_argument("--output-json", default="")
    parser.add_argument("--output-svg", default="")
    return parser.parse_args()

Использование ONNX-runtime вместо SB3 нужно по двум причинам: eval должен проверять именно тот артефакт, который окажется в backend, и не должен унаследовать SB3-стек (нормализаций, exploration noise). ONNX-модель — «чистая» функция от observation к action_logits, и argmax по логитам даёт детерминированное действие.

Эпизод считается успешным при двух условиях: агент достиг финального waypoint в пределах goal_radius_m и не вышел за пределы коридора (oob_threshold_m). Из 20 эпизодов вычисляются success rate, mean steps to goal и max distance from corridor centerline. Метрики сохраняются в <output_dir>/metrics.json. SVG-отчёт визуализирует траектории всех эпизодов с раскраской по успеху.

6.5.2 Структура evidence-папки и воспроизводимость

Каждая ревизия модели сохраняется в отдельной директории python/training/artifacts/<model-name>/<version>/. Содержимое такой директории формирует самодостаточный «паспорт ревизии», по которому она однозначно идентифицируется и воспроизводится. Структура зафиксирована в model_artifacts.py:

  • cardboard-corridor-ppo-v9-rev16_sb3.zip — SB3-чекпоинт (policy + optimizer + replay-state);
  • cardboard-corridor-ppo-v9-rev16.onnx — экспортированная ONNX-модель;
  • metadata.json — паспорт ревизии: гиперпараметры, сценарий, путь к чекпоинту, runtime/vehicle compatibility;
  • metrics.json — KPI после прогона evaluate_ab_policy.py;
  • checkpoints/ — промежуточные SB3-чекпоинты с шагом --checkpoint-freq;
  • best_model/ — лучший чекпоинт, сохранённый EvalCallback (если eval включён);
  • tb_v9/ — TensorBoard-логи прогона.

metadata.json достаточно подробен, чтобы по нему можно было воссоздать тот же прогон. Он содержит секции name/version, compatibility, observationSchema, actionSchema, wrappers, hyperparameters и monitoring. Пример секции из реальной ревизии:

{
  "name": "cardboard-corridor-ppo-v9-rev16",
  "version": "1.0.0",
  "policyId": "cardboard-corridor-ppo-v9-rev16:1.0.0",
  "format": "sb3",
  "compatibility": {
    "runtimeModes": ["unity-sim", "real-robot"],
    "vehicleIds": ["vehicle.prometeo.sport.v1", "vehicle.ks0223.arcade.blue.v1"],
    "robotKinds": ["ks0223"]
  },
  "wrappers": {"discreteAction": true, "delayedAction": false,
               "antiSpinReward": false, "imageAug": true, "latencySteps": 1},
  "hyperparameters": {"learningRate": 0.0003, "nSteps": 256, "batchSize": 64,
                      "nEpochs": 4, "gamma": 0.99, "clipRange": 0.2, "entCoef": 0.1}
}

Поле compatibility перечисляет runtime-моды, vehicle-id и robot-kind, к которым модель применима. Backend сверяет compatibility с текущей сессией и отказывается активировать неподходящую модель — fail-safe против случайной активации policy с гоночной машины на KS0223-сессии.

6.5.3 Сравнение revisions (rev16 → rev30+)

Полный список revisions для cardboard-corridor-PPO-v9 на момент написания работы насчитывает примерно 25 успешных прогонов. В таблице 6.2 представлены ключевые ревизии и их особенности, иллюстрирующие траекторию исследовательской работы.

Таблица 6.2 — Сравнительная таблица ревизий cardboard-corridor-ppo-v9

Revision Изменения KPI (sim-eval) Заметка
rev10 Базовая v9: discrete action, image aug, latency=0 success≈55% Первая стабильная ревизия
rev16 Добавлен latency=1, ent_coef=0.1 success≈60% Эталон для дальнейших сравнений
rev18 Multi-agent (1 Unity x N), real_cam_postprocess success≈58% Performance-rev, KPI без регрессии
rev24 Resume с rev18, новый scenario success≈45% Молчаливый ent_coef=0.02 — degraded
rev29 Default ent_coef поднят до 0.1, fix success≈62% Восстановление эталона
rev30 Heavy-DR: spawn jitter, ultrasonic noise success=0% Первый провал на heavy-DR
rev31-rev35 Bisect heavy-DR, 6/6 seed-ов = 0% 0% Подтверждена variance, не bug
rev37 Frame stack k=4, n_steps=512, n_epochs=10 в исследовании Plan 2
rev39 Latency randomization, ultrasonic noise 0.02 в исследовании Plan 4
rev40 R3M frozen feature extractor, 256-d в исследовании Plan 5
rev42 RecurrentPPO с LSTM hidden_size=128 в исследовании Plan 5 alt

Ревизии rev30-rev35 заслуживают комментария. Шесть прогонов на разных seed-ах под одной конфигурацией heavy-DR показали 0% success rate в каждом случае. Env-revert bisect (откат к env-коду rev16) с rev16-ными гиперпараметрами восстанавливал KPI в 60% диапазон. Вывод: проблема — в дисперсии PPO под heavy-DR, а не в коде среды.

После rev35 reward-шейпинг как направление отвергнут, фокус сместился на архитектурные изменения: frame stacking (rev37+), pretrained feature extractor (rev40), recurrent policy (rev42). Это стандартные приёмы повышения семплоэффективности vision RL; их применение — открытая исследовательская часть, не претендующая на завершённость. Сама программная обвязка (CLI-флаги, callbacks, ONNX-export) одинаково обслуживает все варианты.

6.6 Экспорт ONNX-артефакта и связь с backend

6.6.1 torch.onnx.export: тонкие места

После завершения тренировки SB3-чекпоинт превращается в ONNX-артефакт через export_to_onnx_discrete. Backend на стороне AutopilotService использует Microsoft.ML.OnnxRuntime для inference; SB3-чекпоинт .zip содержит torch-pickle-данные, требующие интерпретатора Python, и для backend-а непригоден.

Реализация экспорта решает три тонких проблемы. Первая — SB3 хранит policy как сложный объект; torch.onnx.export(policy) не работает, потому что forward-сигнатура MultiInputPolicy принимает Dict-обсервацию, не поддерживаемую ONNX-export-ом. Решение — обёртка DiscretePolicyWrapper с двумя именованными tensor-входами:

class DiscretePolicyWrapper(torch.nn.Module):
    def __init__(self, sb3_policy):
        super().__init__()
        self.features_extractor = sb3_policy.features_extractor
        self.mlp_extractor = sb3_policy.mlp_extractor
        self.action_net = sb3_policy.action_net

    def forward(self, image: torch.Tensor, ultrasonic: torch.Tensor):
        image_chw = image.permute(0, 3, 1, 2).contiguous()
        obs = {"image": image_chw, "ultrasonic": ultrasonic}
        features = self.features_extractor(obs)
        latent_pi, _ = self.mlp_extractor(features)
        return self.action_net(latent_pi)

Вторая проблема — формат image-tensor. Тренировочный код использует layout (batch, H, W, C) (channels-last, native для Unity JPEG-кадра); NatureCNN ожидает (batch, C, H, W). Перестановка через permute внутри обёртки даёт ONNX-модели channels-last на входе (то, что отдаёт backend без преобразований), а внутренние слои получают channels-first.

Третья проблема — выходной формат. PPO-policy возвращает Categorical distribution; ONNX не поддерживает sampling. Решение — экспортировать raw action_logits (batch, 5), без softmax и sampling. Backend выполняет argmax и получает дискретное действие.

torch.onnx.export(
    wrapper,
    (dummy_img, dummy_ultra),
    str(output_path),
    input_names=["image", "ultrasonic"],
    output_names=["action_logits"],
    external_data=False,
    dynamic_axes={
        "image": {0: "batch"},
        "ultrasonic": {0: "batch"},
        "action_logits": {0: "batch"},
    },
    opset_version=11,
)

opset_version=11 — минимальная версия, поддерживающая все используемые операторы. dynamic_axes оставляет batch-размерность изменяемой, чтобы при желании переключиться на batched-inference без переэкспорта.

6.6.2 metadata.json и metrics.json: формат

ONNX-артефакт сам по себе не содержит достаточно информации для backend. Нужны имя модели, версия, runtime-моды, vehicle-id и формат observation/action. Информация хранится в metadata.json, формируемом build_model_metadata.

Поле policyId = "<slugified-name>:<version>" — первичный ключ модели в registry. format указывает, какой файл загружать ("onnx" направляет в OnnxRuntime, "sb3" — legacy). observationSchema описывает форму и dtype каждого входа; backend сверяет схему с конфигурацией камеры и отказывается активировать несовместимые модели. actionSchema описывает выходы (size=5, type=discrete-categorical, mapping={0: "DirStop", ...}); backend по этому маппингу преобразует argmax-индекс в текстовую команду для TCP-канала 5051.

Файл metrics.json содержит результаты evaluate_ab_policy.py. Backend не использует его в runtime, но публикует в Web UI как «паспорт качества модели» — оператор видит success rate перед активацией, что предотвращает случайную активацию модели с низким KPI.

6.6.3 rusim model install / activate / bind

Доставка ONNX-артефакта в backend выполняется через CLI rusim model install (subcommand в python/sim_client/cli.py). Команда принимает путь к ONNX-файлу, автоматически находит рядом metadata.json и metrics.json, и загружает всё в backend через /api/models/upload.

$ rusim model install python/training/artifacts/cardboard-corridor-ppo-v9-rev16/1.0.0/cardboard-corridor-ppo-v9-rev16.onnx

Команда _model_install использует SimClient.upload_model, передающий артефакт как multipart/form-data с полями file, metadata, metrics. Backend валидирует metadata, регистрирует модель в БД и возвращает policyId. Флаг --activate делает свежую модель активной для дефолтного binding-а.

rusim model activate <model_id> вызывает /api/models/activate для переключения активной ревизии без переустановки. Персональные bindings создаются через Web UI: оператор выбирает clientId, runtime-mode и модель из реестра; backend-эндпоинт /api/model-bindings сохраняет тройку (clientId, runtimeMode, modelId). При запросе на инференс AutopilotService сначала ищет binding по этой паре и только при его отсутствии откатывается к активной по умолчанию модели. Это позволяет в одном backend-инстансе держать несколько активных моделей одновременно.

С точки зрения тренировочной обвязки цикл «обучить — экспортировать — поставить в backend» проходит без пересборки backend-а или Unity-runtime: всё взаимодействие идёт через один HTTP-эндпоинт и один CLI-инструмент.

6.7 Тестовое покрытие

Python-обвязка обучения покрыта набором pytest-тестов в python/tests/training/. Тесты разделены на две группы: чистые unit-тесты, не требующие ни Unity-runtime, ни GPU (test_discrete_action_wrapper.py, test_image_aug_wrapper.py, test_latency_wrapper.py, test_anti_spin_reward.py, test_resume_curriculum.py), и интеграционные тесты, требующие живых Unity-инстанций и активируемые переменной окружения (test_multi_runtime_launch.py). Совокупный размер test-suite составляет около 570 строк — на порядок меньше, чем сама обвязка, но покрывает критические инварианты, разрушение которых тихо ломает обучение.

Таблица 6.3 — Тестовое покрытие Python-обвязки

Файл LOC Тип Что проверяет
test_discrete_action_wrapper.py 130 unit Маппинг 5 дискретных действий → (throttle, steer); ошибки на out-of-range
test_image_aug_wrapper.py 138 unit Сохранение dtype/shape obs["image"]; eval=True отключает рандомизацию
test_latency_wrapper.py 102 unit Action queue, neutral action на первых N шагах, randomize delay
test_anti_spin_reward.py 105 unit Штраф только после порога повторений; сброс при смене действия
test_resume_curriculum.py 37 unit Curriculum stage по num_timesteps после resume; CLI-флаг --start-timestep
test_multi_runtime_launch.py 59 integration 3 Unity-инстанции отвечают на /health; принимают /reset

6.7.1 Wrapper-тесты

Тесты на четыре env-обёртки фиксируют их поведенческие инварианты. Самый важный из них — соответствие таблицы ACTION_TABLE ожидаемым действиям. В test_discrete_action_wrapper.py используется минимальный mock-окружение:

class _ContinuousActionStub(gym.Env):
    def __init__(self):
        self.action_space = spaces.Box(-1.0, 1.0, (2,), dtype=np.float32)
        self.observation_space = spaces.Box(0, 1, (4,), dtype=np.float32)
        self.last_action: np.ndarray | None = None

    def step(self, action):
        self.last_action = np.asarray(action, dtype=np.float32).copy()
        return np.zeros(4, dtype=np.float32), 0.0, False, False, {}

Через mock вокруг DiscreteActionWrapper подаётся каждое из пяти действий, и тест проверяет, что last_action совпадает в точности с ожидаемой парой (throttle, steer) из ACTION_TABLE. Дополнительно проверяется, что action_space обёрнутой среды — Discrete(5), а не Box. Тест существует именно потому, что ACTION_TABLE за время разработки изменялась дважды: первый раз — при попытке использовать (0.5, ±1.0) для DirLeft/Right, второй — при возврате к (0, ±1) после открытия, что Ks0223Vehicle корректно реализует чистый дифференциальный привод. Без теста любая последующая правка таблицы тихо нарушила бы соответствие между sim-policy и backend-командами.

test_image_aug_wrapper.py проверяет три ключевых свойства. Первое — сохранение типа: после применения wrapper obs["image"].dtype остаётся uint8, а obs["image"].shape остаётся (84, 84, 3). Второе — eval-режим: при enable=False все аугментации детерминированно отключаются, и obs возвращается без изменений. Третье — статистическая проверка: на серии шагов средняя яркость должна оставаться в разумных пределах от исходного значения, иначе аугментация ломала бы распределение настолько, что policy не имела бы шансов на обучение.

test_latency_wrapper.py проверяет очередь действий: при delay_steps=2 первые два шага возвращают neutral-action (нулевое действие — DirStop для дискретного, (0, 0) для непрерывного), а с третьего шага среда получает первое поданное действие. Также проверяется randomize-режим: при delay_max=4 фактический lag варьируется в [delay_steps, delay_max] и в среднем по большому числу шагов сходится к ожидаемому диапазону.

test_anti_spin_reward.py проверяет два инварианта: штраф не применяется до достижения порога (по умолчанию пять повторений одного действия), и счётчик повторений сбрасывается при смене действия. Эти проверки гарантируют, что wrapper не «душит» policy случайными штрафами на самых первых шагах эпизода, когда повторение естественно (например, DirForward в начале коридора).

6.7.2 Multi-runtime launch test

Один интеграционный тест — test_multi_runtime_launch.py — проверяет, что три Unity-инстанции, поднятые ./rusim server up --count 3, корректно отвечают на /health и принимают /reset. Тест активируется только при выставленной переменной окружения RUSIM_MULTI_RUNTIME_TEST=1; без неё pytest.skip пропускает его, что нужно для CI, в котором Unity недоступен.

@pytest.mark.skipif(
    os.environ.get("RUSIM_MULTI_RUNTIME_TEST") != "1",
    reason="requires 3 live Unity runtimes on :8000-:8002",
)
def test_three_runtimes_healthy():
    for port in (8000, 8001, 8002):
        r = requests.get(f"http://127.0.0.1:{port}/health", timeout=5)
        assert r.ok, f"port {port} returned {r.status_code}"
        body = r.json()
        assert body.get("status") == "ok", f"port {port} unhealthy: {body}"

Тест короткий, но он закрывает важный практический кейс: оператор хочет запустить meta-multi-agent-обучение и сначала проверить, что три параллельных Unity-инстанции живы и принимают reset с теми же trackParams, что и ожидаются в реальной тренировке. Запуск теста занимает порядка 15 секунд и не требует никакого тренировочного оборудования; перед длительным прогоном (несколько часов) это разумный smoke-test.

6.7.3 Resume curriculum test

test_resume_curriculum.py фиксирует тонкий инвариант, обнаруженный после неудачной попытки resume-обучения. Сценарий: тренировка останавливается на 30 000 шагов; оператор перезапускает её с --resume <checkpoint>. Без специальной обработки MazeCurriculumCallback интерпретирует num_timesteps=0 (значение в свежеподнятом callback-объекте) как «начало обучения» и переключается в stage-A-easy, хотя реальный num_timesteps модели уже равен 30 000 и должен соответствовать stage-B-medium.

def test_resume_with_start_timestep_sets_num_timesteps():
    cb = MazeCurriculumCallback(stages=DEFAULT_STAGES, verbose=0)
    idx_at_30k = cb._current_stage(30_000)
    assert DEFAULT_STAGES[idx_at_30k].name == "stage-B-medium"
    idx_at_5k = cb._current_stage(5_000)
    assert DEFAULT_STAGES[idx_at_5k].name == "stage-A-easy"

Тест проверяет, что внутренняя функция _current_stage возвращает правильный stage при заданном num_timesteps. Кроме того, отдельная проверка убеждается, что CLI-флаг --start-timestep присутствует в argparse-схеме старого тренировочного скрипта train_cardboard_corridor.py. Этот флаг — критичная часть resume-механизма, и его удаление при рефакторинге сломало бы curriculum-resume для всех ревизий, использующих старую тренировочную обвязку. Закрепление инварианта в тесте предотвращает такие регрессии.

Совокупно набор тестов покрывает четыре wrapper-инварианта, два инварианта resume-механизма и два кейса multi-runtime-запуска. Это не полное покрытие — env-классы (ABCorridorVisionEnv, MultiAgentVisionVecEnv, MetaMultiAgentVecEnv) тестируются только косвенно через тренировочные прогоны, потому что построить корректный mock Unity-runtime с реалистичными observation-кадрами оказывается непрактично. Этот компромисс принят сознательно: тесты ловят наиболее частые регрессии в wrapper-стеке (где они и наблюдались), а корректность env-классов проверяется fact-of-training — несходимость PPO в первых 5000 шагов даёт быстрый сигнал о поломке.