MERCI SPACE
Навчальні матеріали

Лабораторна робота 21

Лабораторна робота №21. Автоматичне сортування

Мета: спроєктувати й перевірити інтеграційний автомат подавача, конвеєра, сенсора, камери, mock-MG400 і GripperAdapter, який координує готовність, класифікує деталь, формує символічний маршрут, вимірює цикл і переходить у безпечний стан при невпевненості, тайм-ауті або втраті підсистеми.

Результати навчання та передумови

Після роботи студент уміє:

  1. визначити контракти готовності та завершення шести підсистем;
  2. реалізувати послідовний автомат із локальними тайм-аутами;
  3. відокремити REJECT через невизначеність від FAULT через несправність;
  4. не використовувати застарілу gripper_control_by_mg400, а працювати через GripperAdapter/mock;
  5. зупинити всі mock-виходи й вимагати ручної звірки після відмови або перезапуску;
  6. обчислити throughput, success/reject/fault rate та розподіл причин.

Передумови: ЛР-05–10, ЛР-13, ЛР-16–18 і ЛР-20.

Середовище виконання

СередовищеСтатусПримітка
Google ColabдозволеноПовний mock зі стандартною бібліотекою
Локальний ПКосновнеJSON-сценарії, assertions, журнали
Raspberry PiдозволеноТільки mock; без GPIO, UART, USB і API MG400
СимуляціяосновнеСимволічні команди та логічний час
Фізичний комплекс MERC-I5не використовуєтьсяАктивація, рух, I/O та грипер заборонені

Необхідні знання та матеріали

Ризики та правила безпеки

Ризики

Різні підсистеми можуть мати несумісні або застарілі стани; деталь може бути втрачена між сенсором і захопленням; камера — повернути невпевненість; грипер — не підтвердити захоплення; robot action — зависнути. Повтор повідомлення може дублювати цикл.

Заборонені дії

  • не імпортувати або відновлювати gripper_control_by_mg400;
  • не викликати мережеві команди MG400, UART грипера, GPIO чи реле;
  • не продовжувати pick при UNKNOWN, низькій confidence або відсутній трансформації;
  • не виконувати automatic retry рухової команди;
  • не скидати FAULT після restart без фактичної звірки.

Умови негайної зупинки

Будь-яка неготова підсистема, timeout, camera lost, invalid class, robot/gripper no-ack, дублікат cycle ID, перезапуск посеред циклу або невідомий стан. Mock-виходи переходять у STOP, стан — RECOVERY_REQUIRED.

Безпечний стан

Подавач і конвеєр STOP; mock-robot не приймає нові goals; mock-gripper HOLD_OR_STOP (конкретна фізична реакція ще не підтверджена); причина latched; автоматичний restart відсутній.

Передпусковий чекліст

  • mode=simulation-only.
  • Усі маршрути символічні, без координат стенда.
  • Адаптери лише записують журнал.
  • Є окремі timeout для feed, transport, robot і gripper.
  • UNKNOWN не створює pick-plan.
  • Recovery вимагає ручного ack та reconciliation.

Хід виконання роботи

  1. Перевірити route/reject gate.
  2. Задати конфігурацію й сценарії.
  3. Реалізувати інтеграційний FSM і safe stop.
  4. Запустити нормальний, reject і fault cases.
  5. Перевірити restart та ручне відновлення.
  6. Розрахувати метрики.

Методичні вказівки й теоретичні відомості

1. Оркестрація, а не пряме керування

Оркестратор працює з контрактами ready, start, status, stop, ack, а конкретні драйвери приховані за адаптерами. Це дозволяє замінити mock апаратною реалізацією лише після окремої валідації. Команда не є доказом завершення: кожен етап має незалежне підтвердження.

2. REJECT, FAULT, RECOVERY_REQUIRED

REJECT — коректно оброблена невизначеність даних: виріб не класифіковано, новий рух не планується. FAULT — порушення контракту/тайм-аут. Після safe stop автомат переходить у RECOVERY_REQUIRED, бо положення деталі не можна вгадувати. COMPLETE у цій роботі означає завершення mock-циклу, а не фізичне сортування.

Текстовий опис рисунка: після перевірки готовності автомат координує подавання, транспортування, інспекцію, mock-захоплення й розміщення; невідомий клас веде у REJECT, а несправності — через FAULT у RECOVERY_REQUIRED до ручної звірки.

Mermaid
stateDiagram-v2
    [*] --> IDLE
    IDLE --> CHECK_READY: start
    CHECK_READY --> FEEDING: all ready
    FEEDING --> TRANSPORTING: one part confirmed
    TRANSPORTING --> INSPECTING: sensor confirmed + stop ack
    INSPECTING --> PICKING: class + route + transform valid
    INSPECTING --> REJECT: UNKNOWN / low confidence
    PICKING --> PLACING: robot + grip ack
    PLACING --> COMPLETE: placement ack
    CHECK_READY --> FAULT: subsystem unavailable
    FEEDING --> FAULT: timeout / double feed
    TRANSPORTING --> FAULT: timeout
    INSPECTING --> FAULT: camera lost
    PICKING --> FAULT: robot/gripper timeout
    FAULT --> RECOVERY_REQUIRED: safe stop
    RECOVERY_REQUIRED --> IDLE: manual reconciliation

Рис. 1. Інтеграційний автомат автоматичного сортування

3. Джерела

Виконання лабораторної роботи

Крок 1. Safe-smoke рішення про маршрут

Python
# safe-smoke
def decision(label: str, confidence: float, routes: dict[str, str], threshold: float) -> tuple[str, str | None]:
    if label not in routes or confidence < threshold:
        return "REJECT", None
    return "ACCEPT", routes[label]

routes = {"red-cylinder": "BOX_A", "blue-cube": "BOX_B"}
assert decision("red-cylinder", 0.90, routes, 0.80) == ("ACCEPT", "BOX_A")
assert decision("red-cylinder", 0.79, routes, 0.80) == ("REJECT", None)
assert decision("UNKNOWN", 1.00, routes, 0.80) == ("REJECT", None)
print("safe-smoke: accept/reject gate PASS")

Очікуваний результат: safe-smoke: accept/reject gate PASS.

Критерій правильності: UNKNOWN не має маршруту навіть із confidence 1.

Якщо результат не отримано: не переходити до інтеграційного FSM; виправити gate.

Крок 2. Конфігурація та fixtures

Збережіть як lab21-config.json.

JSON
{
  "mode":"simulation-only",
  "confidence_min":0.80,
  "timeouts":{"feed":3,"transport":4,"robot":3,"gripper":2},
  "routes":{"red-cylinder":"BOX_A","blue-cube":"BOX_B"},
  "reject_zone":"MANUAL_REVIEW"
}

Збережіть як lab21-cases.json.

JSON
[
  {"id":"normal","ready":true,"feed":2,"transport":3,"camera":true,"label":"red-cylinder","confidence":0.92,"robot":2,"gripper":1,"grip_ok":true,"restart":false},
  {"id":"unknown","ready":true,"feed":1,"transport":1,"camera":true,"label":"UNKNOWN","confidence":0.35,"robot":0,"gripper":0,"grip_ok":false,"restart":false},
  {"id":"camera-lost","ready":true,"feed":1,"transport":1,"camera":false,"label":"red-cylinder","confidence":0.95,"robot":0,"gripper":0,"grip_ok":false,"restart":false},
  {"id":"robot-timeout","ready":true,"feed":1,"transport":1,"camera":true,"label":"blue-cube","confidence":0.90,"robot":4,"gripper":1,"grip_ok":true,"restart":false},
  {"id":"grip-fail","ready":true,"feed":1,"transport":1,"camera":true,"label":"blue-cube","confidence":0.90,"robot":1,"gripper":1,"grip_ok":false,"restart":false},
  {"id":"sensor-timeout","ready":true,"feed":1,"transport":5,"camera":true,"label":"red-cylinder","confidence":0.90,"robot":1,"gripper":1,"grip_ok":true,"restart":false},
  {"id":"restart-mid-cycle","ready":true,"feed":1,"transport":1,"camera":true,"label":"red-cylinder","confidence":0.90,"robot":1,"gripper":1,"grip_ok":true,"restart":true}
]

Очікуваний результат: один complete, один reject і п'ять recovery cases.

Критерій правильності: конфігурація не містить координат/IP; кожна відмова має унікальний id.

Якщо результат не отримано: перевірити JSON; не видаляти restart case.

Крок 3. Повна mock-реалізація

Збережіть як lab21_sorting.py.

Python
from __future__ import annotations

import argparse
import json
from dataclasses import asdict, dataclass, field
from enum import Enum
from pathlib import Path


class State(str, Enum):
    IDLE="IDLE"; CHECK_READY="CHECK_READY"; FEEDING="FEEDING"
    TRANSPORTING="TRANSPORTING"; INSPECTING="INSPECTING"
    PICKING="PICKING"; PLACING="PLACING"; COMPLETE="COMPLETE"
    REJECT="REJECT"; FAULT="FAULT"; RECOVERY_REQUIRED="RECOVERY_REQUIRED"


@dataclass
class Outcome:
    case_id: str
    state: str
    reason: str
    route: str | None
    outputs: dict[str, str]
    transitions: list[str] = field(default_factory=list)


class MockCell:
    def __init__(self, cfg: dict) -> None:
        if cfg.get("mode") != "simulation-only":
            raise ValueError("only simulation-only mode")
        self.cfg = cfg
        self.state = State.IDLE
        self.outputs = {"feeder":"STOP", "conveyor":"STOP", "robot":"NO_GOAL", "gripper":"STOP"}
        self.log: list[str] = []
        self.reason = ""

    def move(self, target: State, reason: str) -> None:
        self.log.append(f"{self.state.value}->{target.value}:{reason}")
        self.state = target

    def safe_stop(self) -> None:
        self.outputs.update(feeder="STOP", conveyor="STOP", robot="NO_NEW_GOAL", gripper="HOLD_OR_STOP")

    def fail(self, reason: str) -> None:
        self.reason = reason
        self.safe_stop()
        self.move(State.FAULT, reason)
        self.move(State.RECOVERY_REQUIRED, "manual-reconciliation-required")

    def run(self, case: dict) -> Outcome:
        self.move(State.CHECK_READY, "start")
        if not bool(case["ready"]):
            self.fail("subsystem-not-ready")
        else:
            self.move(State.FEEDING, "all-ready")
            self.outputs["feeder"] = "RUN"
        if self.state is State.FEEDING:
            if int(case["feed"]) > int(self.cfg["timeouts"]["feed"]):
                self.fail("feed-timeout")
            else:
                self.outputs.update(feeder="STOP", conveyor="RUN")
                self.move(State.TRANSPORTING, "one-part-confirmed")
        if self.state is State.TRANSPORTING:
            if int(case["transport"]) > int(self.cfg["timeouts"]["transport"]):
                self.fail("sensor-timeout")
            else:
                self.outputs["conveyor"] = "STOP"
                self.move(State.INSPECTING, "part-stopped")
        if self.state is State.INSPECTING:
            if bool(case["restart"]):
                self.fail("restart-mid-cycle")
            elif not bool(case["camera"]):
                self.fail("camera-lost")
            elif str(case["label"]) not in self.cfg["routes"] or float(case["confidence"]) < float(self.cfg["confidence_min"]):
                self.reason = "classification-rejected"
                self.move(State.REJECT, self.reason)
            else:
                self.move(State.PICKING, "route-and-transform-valid")
                self.outputs["robot"] = "MOCK_GOAL"
        if self.state is State.PICKING:
            if int(case["robot"]) > int(self.cfg["timeouts"]["robot"]):
                self.fail("robot-timeout")
            elif int(case["gripper"]) > int(self.cfg["timeouts"]["gripper"]) or not bool(case["grip_ok"]):
                self.fail("grip-not-confirmed")
            else:
                self.outputs.update(robot="MOCK_GOAL_DONE", gripper="MOCK_GRIPPED")
                self.move(State.PLACING, "pick-confirmed")
        route = None
        if self.state is State.PLACING:
            route = str(self.cfg["routes"][str(case["label"])])
            self.outputs.update(robot="NO_GOAL", gripper="STOP")
            self.move(State.COMPLETE, f"placed-symbolically:{route}")
        elif self.state is State.REJECT:
            route = str(self.cfg["reject_zone"])
            self.safe_stop()
        assert self.outputs["feeder"] == self.outputs["conveyor"] == "STOP"
        return Outcome(str(case["id"]), self.state.value, self.reason or "ok", route, dict(self.outputs), list(self.log))

    def recover(self, operator_ack: bool, reconciled: bool, area_clear: bool) -> bool:
        if self.state is not State.RECOVERY_REQUIRED or not (operator_ack and reconciled and area_clear):
            return False
        self.safe_stop()
        self.move(State.IDLE, "manual-recovery")
        return True


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--config", type=Path, required=True); p.add_argument("--cases", type=Path, required=True)
    p.add_argument("--out", type=Path, required=True); args = p.parse_args()
    cfg = json.loads(args.config.read_text(encoding="utf-8"))
    cases = json.loads(args.cases.read_text(encoding="utf-8"))
    results: list[Outcome] = []
    for case in cases:
        cell = MockCell(cfg); result = cell.run(case); results.append(result)
        if result.state == "RECOVERY_REQUIRED":
            assert not cell.recover(False, True, True)
            assert cell.recover(True, True, True)
    states = {r.case_id:r.state for r in results}
    assert states["normal"] == "COMPLETE" and states["unknown"] == "REJECT"
    for key in ("camera-lost","robot-timeout","grip-fail","sensor-timeout","restart-mid-cycle"):
        assert states[key] == "RECOVERY_REQUIRED"
    args.out.write_text(json.dumps([asdict(r) for r in results], ensure_ascii=False, indent=2), encoding="utf-8")
    print("PASS: COMPLETE=1 REJECT=1 RECOVERY_REQUIRED=5")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Очікуваний результат: PASS: COMPLETE=1 REJECT=1 RECOVERY_REQUIRED=5.

Критерій правильності: у кожному terminal state feeder/conveyor STOP; fault не відновлюється без трьох ручних gates.

Якщо результат не отримано: аналізувати перший неправильний перехід; не підключати апаратні адаптери.

Крок 4. Запуск і метрики

Bash
python lab21_sorting.py --config lab21-config.json --cases lab21-cases.json --out lab21-results.json

Побудуйте таблицю причин і розрахуйте: success_rate, reject_rate, fault_rate, середню кількість переходів, умовний throughput як completed / sum(logical durations).

Очікуваний результат: усі сім cases у JSON, причини не порожні.

Критерій правильності: restart не завершується COMPLETE; UNKNOWN не містить mock-goal.

Якщо результат не отримано: перевірити журнал і вихідні fixtures; не змінювати expected states.

Перенесення на MERC-I5: адресні прогалини

Сценарії перевірки

ТипСценарійСтанРеакція
позитивнийусі ack, відомий класCOMPLETEсимволічний BOX_A
негативнийUNKNOWNREJECTбез robot goal
негативнийcamera lostRECOVERY_REQUIREDsafe stop
негативнийrobot timeoutRECOVERY_REQUIREDno new goal
негативнийgrip failRECOVERY_REQUIREDmanual reconciliation
граничнийtransport > timeoutRECOVERY_REQUIREDstop conveyor
граничнийrestart mid-cycleRECOVERY_REQUIREDno automatic resume

Таблиці вимірювань і метрики

CaseКлас/confidenceМаршрутСтанПричинаПереходівFeeder/Conveyor safeRecovery без ack
normalblocked
unknown
camera-lostblocked
robot-timeoutblocked
grip-failblocked
sensor-timeoutblocked
restart-mid-cycleblocked

Вимоги до звіту

Див. загальні вимоги. Додайте контракти адаптерів, FSM, конфігурацію, fixtures, код, журнали, метрики, recovery evidence та явну межу «mock-only; жоден фізичний модуль не запускався».

Критерії оцінювання

СкладоваЧасткаЩо оцінюється
Підготовка10%контракти, конфігурація, safety
Реалізація40%інтеграційний FSM, адаптери, gates, safe stop
Перевірка25%7 cases, тайм-аути, restart і recovery
Аналіз15%причини, throughput, reject/fault розмежування
Звіт10%журнали, метрики, відтворюваність
Разом100%

Контрольні питання

  1. Чому оркестратор не повинен знати UART/TCP деталі драйвера?
  2. Чим REJECT відрізняється від FAULT?
  3. Чому grip command потребує незалежного підтвердження?
  4. Як уникнути повторного виконання cycle ID?
  5. Чому restart вимагає reconciliation?
  6. Які фізичні метрики не можна замінити mock-результатами?

Висновки

Опишіть перевірену координацію підсистем, причини reject/fault, реакцію на restart, метрики mock-циклів і мінімальний набір доказів для фізичної апробації.

MERCI SPACE