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

Лабораторна робота 20

Лабораторна робота №20. Подавач, конвеєр і сенсор як автомат станів

Мета: реалізувати детермінований автомат зі станами IDLE, FEEDING, TRANSPORTING, DETECTED, COMPLETE, FAULT, тайм-аутами, захистом від подвійної подачі, контролем втрати/заклинювання деталі, журналом переходів і лише ручним відновленням; перевірити його повністю на mock-компонентах.

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

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

  1. задавати дозволені переходи та інваріанти станів;
  2. відокремлювати команди від підтверджень стану;
  3. використовувати monotonic/logical time для тайм-аутів;
  4. ін'єктувати подвійне подавання, заклинювання, втрату деталі та stuck-sensor;
  5. гарантувати feeder=STOP і conveyor=STOP у FAULT;
  6. виконувати відновлення лише після усунення причини, огляду та ручного підтвердження.

Передумови: ЛР-05–07, Python, Enum, JSON і unit-test мислення.

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

СередовищеСтатусПримітка
Google ColabдозволеноСтандартна бібліотека, логічні ticks
Локальний ПКосновнеMock-автомат і JSON-сценарії
Raspberry PiдозволеноЛише mock; без GPIO та апаратних сервісів
СимуляціяосновнеІн'єкція відмов без створення електричних несправностей
Фізичний комплекс MERC-I5не використовуєтьсяПодавач, конвеєр і сенсор не вмикаються

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

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

Ризики

Повторна подача може затиснути дві деталі; відсутність сенсорної події може означати втрату або заклинювання; stuck-active — заблоковану зону; автоматичний reset може запустити механізм біля людини. Симуляційний тайм-аут не є фізично підтвердженим.

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

  • не підключати mock до GPIO, реле чи мережевого сервісу;
  • не створювати обрив, замикання або заклинювання на штатному обладнанні;
  • не переходити FAULT → IDLE лише за часом або після перезапуску;
  • не видавати другу команду feed, доки перша деталь не завершила цикл;
  • не трактувати два звичайні сенсори як safety-rated канал.

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

Тайм-аут подавача/транспорту, count ≠ 1, суперечливий або stuck-сигнал, повторна команда, невідомий стан, втрата зв'язку чи виняток. Реакція: обидва mock-виходи STOP, зафіксований FAULT.

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

feeder_output=STOP, conveyor_output=STOP, помилка latched, автоматичний restart заборонений. Ручне відновлення потребує трьох незалежних булевих підтверджень: оператор, причина усунена, зона вільна.

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

  • Режим конфігурації — simulation-only.
  • Тайм-аути додатні й позначені як навчальні ticks.
  • Адаптери не імпортують GPIO/serial/socket.
  • Кожний FAULT викликає safe_stop().
  • Підготовлені позитивний, негативні й граничні fixtures.
  • Відновлення не виконується автоматично.

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

  1. Перевірити мінімальний контракт станів.
  2. Створити зовнішню конфігурацію сценарію.
  3. Реалізувати FSM і журнал переходів.
  4. Запустити нормальний цикл і відмови.
  5. Перевірити ручне відновлення та метрики.

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

1. Стани та інваріанти

СтанДозволені виходиОбов'язкова умова виходу
IDLEобидва STOPstart і зона вільна
FEEDINGfeeder RUN, conveyor STOPрівно одна деталь підтверджена до timeout
TRANSPORTINGfeeder STOP, conveyor RUNсенсор підтвердив деталь до timeout
DETECTEDобидва STOPвалідний pulse і count = 1
COMPLETEобидва STOPцикл завершено, новий start окремо
FAULTобидва STOPлише ручна процедура recovery

Команда означає намір; підтвердження має походити з незалежного стану/моделі. Тайм-аут вимірюється від входу в стан і перевіряється на кожному tick. Межа в цій ЛР: подія в tick, що дорівнює timeout, приймається; timeout + 1 — відмова.

Текстовий опис рисунка: IDLE переходить через подавання, транспортування та виявлення до COMPLETE; timeout, подвійна подача, втрата чи некоректний сенсор ведуть у latched FAULT, з якого можливе лише ручне відновлення.

Mermaid
stateDiagram-v2
    [*] --> IDLE
    IDLE --> FEEDING: start && zone_clear
    FEEDING --> TRANSPORTING: one part fed
    TRANSPORTING --> DETECTED: sensor active
    DETECTED --> COMPLETE: pulse valid && count=1
    FEEDING --> FAULT: timeout / double feed
    TRANSPORTING --> FAULT: timeout / lost part
    DETECTED --> FAULT: stuck / inconsistent
    FAULT --> IDLE: manual ack + cause removed + area clear
    COMPLETE --> IDLE: new cycle reset

Рис. 1. Автомат подавання й транспортування з latched FAULT

2. Джерела

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

Крок 1. Safe-smoke latched FAULT

Python
# safe-smoke
from enum import Enum

class State(str, Enum):
    IDLE = "IDLE"
    FAULT = "FAULT"

def recover(state: State, *, operator_ack: bool, cause_removed: bool, area_clear: bool) -> State:
    if state is not State.FAULT:
        return state
    return State.IDLE if operator_ack and cause_removed and area_clear else State.FAULT

assert recover(State.FAULT, operator_ack=False, cause_removed=True, area_clear=True) is State.FAULT
assert recover(State.FAULT, operator_ack=True, cause_removed=False, area_clear=True) is State.FAULT
assert recover(State.FAULT, operator_ack=True, cause_removed=True, area_clear=True) is State.IDLE
print("safe-smoke: manual recovery gate PASS")

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

Критерій правильності: жодного автоматичного переходу з FAULT.

Якщо результат не отримано: не продовжувати; виправити recovery gate.

Крок 2. Зовнішня конфігурація

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

JSON
{
  "mode": "simulation-only",
  "feed_timeout_ticks": 3,
  "transport_timeout_ticks": 4,
  "sensor_min_on_ticks": 1,
  "sensor_max_on_ticks": 2
}

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

JSON
[
  {"id":"normal","feed_delay":2,"sensor_delay":3,"sensor_on_ticks":1,"part_count":1,"double_feed":false},
  {"id":"feed-boundary","feed_delay":3,"sensor_delay":4,"sensor_on_ticks":2,"part_count":1,"double_feed":false},
  {"id":"feeder-jam","feed_delay":4,"sensor_delay":1,"sensor_on_ticks":1,"part_count":1,"double_feed":false},
  {"id":"conveyor-jam","feed_delay":1,"sensor_delay":5,"sensor_on_ticks":1,"part_count":1,"double_feed":false},
  {"id":"double-feed","feed_delay":1,"sensor_delay":1,"sensor_on_ticks":1,"part_count":2,"double_feed":true},
  {"id":"lost-part","feed_delay":1,"sensor_delay":2,"sensor_on_ticks":1,"part_count":0,"double_feed":false},
  {"id":"sensor-stuck","feed_delay":1,"sensor_delay":2,"sensor_on_ticks":3,"part_count":1,"double_feed":false}
]

Очікуваний результат: два успішні й п'ять fault-сценаріїв.

Критерій правильності: рівність timeout присутня окремо; відмови не створюються фізично.

Якщо результат не отримано: перевірити JSON; не зменшувати набір сценаріїв.

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

Збережіть як lab20_fsm.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"
    FEEDING = "FEEDING"
    TRANSPORTING = "TRANSPORTING"
    DETECTED = "DETECTED"
    COMPLETE = "COMPLETE"
    FAULT = "FAULT"


@dataclass
class Result:
    case_id: str
    state: str
    fault: str | None
    feeder_output: str
    conveyor_output: str
    transitions: list[str] = field(default_factory=list)


class CellFSM:
    def __init__(self, cfg: dict) -> None:
        if cfg.get("mode") != "simulation-only":
            raise ValueError("hardware mode is forbidden")
        self.cfg = cfg
        self.state = State.IDLE
        self.feeder = "STOP"
        self.conveyor = "STOP"
        self.fault_code: str | None = None
        self.log: list[str] = []

    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.feeder = "STOP"
        self.conveyor = "STOP"

    def fail(self, code: str) -> None:
        self.safe_stop()
        self.fault_code = code
        self.move(State.FAULT, code)

    def run(self, case: dict) -> Result:
        if self.state is not State.IDLE:
            raise RuntimeError("new cycle requires IDLE")
        self.move(State.FEEDING, "start")
        self.feeder = "RUN"
        feed_delay = int(case["feed_delay"])
        if bool(case["double_feed"]) or int(case["part_count"]) > 1:
            self.fail("double-feed")
        elif feed_delay > int(self.cfg["feed_timeout_ticks"]):
            self.fail("feeder-timeout")
        else:
            self.feeder = "STOP"
            self.conveyor = "RUN"
            self.move(State.TRANSPORTING, f"feed-confirmed@{feed_delay}")

        if self.state is State.TRANSPORTING:
            sensor_delay = int(case["sensor_delay"])
            if sensor_delay > int(self.cfg["transport_timeout_ticks"]):
                self.fail("transport-timeout")
            elif int(case["part_count"]) != 1:
                self.fail("part-count-invalid")
            else:
                self.conveyor = "STOP"
                self.move(State.DETECTED, f"sensor-active@{sensor_delay}")

        if self.state is State.DETECTED:
            pulse = int(case["sensor_on_ticks"])
            low = int(self.cfg["sensor_min_on_ticks"])
            high = int(self.cfg["sensor_max_on_ticks"])
            if not low <= pulse <= high:
                self.fail("sensor-pulse-invalid")
            else:
                self.safe_stop()
                self.move(State.COMPLETE, f"pulse-valid:{pulse}")

        assert self.state not in (State.COMPLETE, State.FAULT) or (
            self.feeder == "STOP" and self.conveyor == "STOP"
        )
        return Result(
            str(case["id"]), self.state.value, self.fault_code,
            self.feeder, self.conveyor, list(self.log),
        )

    def recover(self, operator_ack: bool, cause_removed: bool, area_clear: bool) -> bool:
        if self.state is not State.FAULT:
            return False
        if not (operator_ack and cause_removed and area_clear):
            return False
        self.fault_code = None
        self.safe_stop()
        self.move(State.IDLE, "manual-recovery")
        return True


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", type=Path, required=True)
    parser.add_argument("--cases", type=Path, required=True)
    parser.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()
    cfg = json.loads(args.config.read_text(encoding="utf-8"))
    cases = json.loads(args.cases.read_text(encoding="utf-8"))
    results: list[Result] = []
    for case in cases:
        fsm = CellFSM(cfg)
        result = fsm.run(case)
        results.append(result)
        if result.state == "FAULT":
            assert not fsm.recover(False, True, True)
            assert fsm.state is State.FAULT
            assert fsm.recover(True, True, True)
            assert fsm.state is State.IDLE
    states = {result.case_id: result.state for result in results}
    assert states["normal"] == states["feed-boundary"] == "COMPLETE"
    for case_id in ("feeder-jam", "conveyor-jam", "double-feed", "lost-part", "sensor-stuck"):
        assert states[case_id] == "FAULT"
    args.out.write_text(
        json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    print(f"PASS: complete={sum(r.state == 'COMPLETE' for r in results)} fault={sum(r.state == 'FAULT' for r in results)}")
    return 0


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

Очікуваний результат: PASS: complete=2 fault=5 і JSON-журнал.

Критерій правильності: усі кінцеві стани мають обидва виходи STOP; recovery без ack не проходить.

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

Крок 4. Запуск і перевірка меж

Bash
python lab20_fsm.py --config lab20-config.json --cases lab20-cases.json --out lab20-results.json

Очікуваний результат: граничний case з delay = timeout завершується COMPLETE; timeout + 1 — FAULT.

Критерій правильності: журнал містить причину кожного переходу; fault-код не порожній.

Якщо результат не отримано: звірити семантику межі в документації й коді; зупинити сценарій.

Фізична реалізація: адресна прогалина

Еталон потребує підтверджених FeederAdapter, ConveyorAdapter, SensorAdapter, незалежних feedback-сигналів, watchdog, виміряних часів і безпечних апаратних виходів. GPIO 3,3 V не можна з'єднувати з промисловими 24 V без підтвердженої розв'язки.

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

ТипСценарійОчікуванняБезпечна реакція
позитивнийнормальний циклCOMPLETEoutputs STOP
позитивний граничнийdelay = timeoutCOMPLETEжурнал межі
негативнийподвійна подачаFAULT/double-feedнегайний STOP
негативнийподавач/конвеєр timeoutFAULTобидва STOP
негативнийcount = 0FAULT/part-count-invalidне продовжувати
граничнийpulse > maxFAULT/sensor-pulse-invalidlatched fault

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

CaseFeed delayTransport delayPulseCountКінцевий станFaultПереходівRecovery без ack
normalмає бути blocked
feed-boundaryblocked
feeder-jamblocked
conveyor-jamblocked
double-feedblocked
lost-partblocked
sensor-stuckblocked

Метрики: completion/fault rate, частка fault із правильним кодом, максимальна кількість переходів, 100% підтвердження safe outputs у terminal states.

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

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

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

СкладоваЧасткаЩо оцінюється
Підготовка10%конфігурація, інваріанти, safety
Реалізація40%шість станів, тайм-аути, safe stop, журнал
Перевірка25%позитивні, негативні, граничні й recovery-тести
Аналіз15%причини fault, межі timeout, метрики
Звіт10%відтворюваність і чесна межа апробації
Разом100%

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

  1. Чому команда RUN не є підтвердженням руху?
  2. Які інваріанти діють у FAULT?
  3. Як відрізняються jam, lost part і sensor stuck у моделі?
  4. Чому recovery потребує трьох підтверджень?
  5. Як обрати timeout за фізичними вимірюваннями?
  6. Чому simulated short/wire break не треба створювати на MERC-I5?

Висновки

Опишіть перевірені переходи, реакцію на п'ять відмов, поведінку на межі тайм-ауту, ручне відновлення та дані, яких бракує для фізичної реалізації.

MERCI SPACE