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

Лабораторна робота 14

Лабораторна робота №14. Палетизація через Raspberry Pi та Python

Мета: навчитися будувати параметричну Python-оркестрацію палетизації для Raspberry Pi; повторно використовувати adapter-и робота й грипера, генерувати координати без ручного дублювання, виконувати повний dry-run, зберігати прогрес у mock-сховищі, вимагати звірку перед відновленням і журналювати помилки без мережі та фізичного руху.

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

Після виконання студент уміє:

  • читати несекретний параметричний profile поза кодом;
  • генерувати комірки палети й точки approach/place/retract;
  • працювати через RobotAdapter і GripperAdapter, не знаючи transport;
  • виконувати dry-run повного циклу Pick-and-Place;
  • зберігати progress після кожної підтвердженої mock-комірки;
  • після перезапуску переходити у RECONCILIATION_REQUIRED, а не продовжувати автоматично;
  • відхиляти невірну фактичну/очікувану карту комірок;
  • переводити обидва mock-модулі у stop при невдалому захопленні або плані;
  • формувати JSONL-журнал у пам'яті й метрики циклу.

Передумови: ЛР-08, ЛР-11–13, Python, JSON, adapter pattern, автомати станів і правила безпеки. Фізична апробація не входить до цієї редакції.

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

СередовищеСтатусПримітка
Google ColabдозволеноПовний in-memory dry-run зі стандартною бібліотекою
Локальний ПКосновнеPython; без socket, serial, GPIO і файлових записів у smoke
Raspberry PiдозволеноЛише dry-run з mock-adapter-ами; код потребує Python 3.10+, а паспорт ОС охоплено marker-ами фізичної інтеграції
Фізичний комплекс MERC-I5не потрібенMG400 і грипер не підключаються та не рухаються

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

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

Ризики

НебезпекаНаслідокЗахист у цій редакції
Жорстко записані точкитраєкторія не відповідає стендуexternal synthetic profile + strict loader
Невдале захопленнярух без деталі або падінняmock-gripper error зупиняє robot/gripper й progress
Перезапуск посеред циклудубль або пропуск коміркиобов'язковий RECONCILIATION_REQUIRED
Запис progress неатомарнийсуперечливий стану smoke in-memory round-trip; фактичне сховище потребує окремого рішення
Втрата робота/feedbackневідоме виконанняno retry, ERROR_RECONCILIATION_REQUIRED
Віддалений запускнемає локального контролюжодної мережі; фізичний запуск заборонено

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

  • імпортувати/викликати socket, SDK MG400, pyserial, GPIO або застарілий gripper module;
  • додавати IP, pin, пароль, токен, реальні координати чи speed у source code;
  • надсилати EnableRobot, рух, Continue, ClearError, gripper або I/O commands;
  • автоматично відновлювати progress після process restart;
  • вважати mock placed доказом фізично зайнятої комірки;
  • запускати Raspberry Pi сценарій на активній лінії без окремого дозволу й локального оператора.

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

Неузгоджені progress і observed cells, невдале захоплення, pose поза envelope, timeout, duplicate index, помилка store/parser, невідомий state, втрата adapter-а. Для майбутнього обладнання — будь-яка умова з docs/06, включно з людиною/предметом у зоні, невідомою позою, падінням деталі, незвичним звуком/нагрівом.

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

Обидва mock-adapter-и STOPPED, прогрес PAUSED_RECONCILIATION_REQUIRED або ERROR_RECONCILIATION_REQUIRED, новий цикл заблоковано, last_error і journal збережено, автоматичних повторів немає. Фізичний безпечний стан конкретних робота/грипера потребує окремого підтвердження.

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

  • Profile має SYNTHETIC_PALLETIZER_DRY_RUN і strict schema.
  • dry_run=true; external profile не містить IP/pin/speed/реальних frame.
  • Robot/Gripper adapter-и — тільки mock.
  • Progress store — in-memory у smoke.
  • Reconciliation тестується до resume.
  • Фізичний комплекс не підключений.

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

  1. Спроєктувати composition root, adapters, progress store і profile.
  2. Реалізувати parameterized pallet та dry-run.
  3. Перевірити повну послідовність і журнал.
  4. Перевірити controlled pause, restart і reconciliation.
  5. Перевірити gripper/robot faults, межі та відсутність auto-resume.
  6. Оформити метрики, паспорт і звіт.

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

1. Composition root

Єдине місце програми обирає concrete adapter-и. Бізнес-логіка палетизації отримує RobotAdapter, GripperAdapter і ProgressStore через конструктор. Таким чином dry-run не містить умов if real_hardware у кожному кроці.

ІнтерфейсMock цієї ЛРМайбутня реалізація
RobotAdapterзапис plan poses у пам'ятьлише після ЛР-13/API validation
GripperAdapterлічильник synthetic objectsлише після ЛР-08/protocol validation
ProgressStoreJSON round-trip у пам'ятіатомарне локальне сховище після рішення
journalJSONL string у пам'ятілокальний журнал із retention policy

2. Progress і відновлення

Checkpoint після mock-комірки містить next_index, перелік placed_indices, status і error. Після нового process session навіть валідний checkpoint не доводить фактичне положення деталі. Тому конструктор переводить незавершений progress у RECONCILIATION_REQUIRED; resume дозволяється лише коли observed map точно збігається й оператор явно підтвердив.

3. Потік dry-run

Текстовий опис рисунка: зовнішня synthetic-конфігурація створює палету; для кожної комірки mock-robot планує підхід до Pick, mock-gripper підтверджує захоплення, robot планує відхід і підхід/укладання/відхід над палетою, після чого progress атомарно оновлюється в mock; pause, restart або будь-яка помилка ведуть до reconciliation gate чи error-stop, а не автоматичного продовження.

Mermaid
flowchart LR
    A["External synthetic config"] --> B["Generate cells"]
    B --> C["Mock Pick approach/pick/retract"]
    C --> D{"Mock grasp confirmed?"}
    D -->|"yes"| E["Mock pallet approach/place/retract"]
    E --> F["Save in-memory progress + journal"]
    F --> G{"More cells?"}
    G -->|"yes"| C
    G -->|"no"| H["DRY_RUN_COMPLETE"]
    D -->|"no"| X["ERROR_RECONCILIATION_REQUIRED"]
    F -->|"pause/restart"| Y["RECONCILIATION_REQUIRED"]

Рис. 1. Ілюстративна оркестрація Python-палетизації у dry-run

4. Конфігурація поза кодом

Файл palletizer.synthetic.json:

JSON
{
  "profile": "SYNTHETIC_PALLETIZER_DRY_RUN",
  "dry_run": true,
  "units": "mm",
  "frame": "SYNTHETIC_USER",
  "rows": 2,
  "columns": 2,
  "pitch_x_mm": 20.0,
  "pitch_y_mm": 25.0,
  "clearance_mm": 30.0,
  "pick_pose": [0.0, -40.0, 10.0, 0.0],
  "pallet_base_pose": [-20.0, 20.0, 10.0, 0.0],
  "envelope_mm": {
    "x": [-100.0, 100.0],
    "y": [-100.0, 100.0],
    "z": [0.0, 120.0],
    "r_deg": [-180.0, 180.0]
  }
}

Усі числа — лише test fixture. Runtime loader читає зовнішній файл; safe-smoke виконує JSON round-trip у пам'яті.

5. Першоджерела

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

Крок 1. Перевірити composition і profile

Зафіксуйте, що hardware adapter-и відсутні. External JSON має dry_run=true, synthetic frame і не має transport address.

Text
RobotAdapter = MockRobot
GripperAdapter = MockGripper
ProgressStore = MemoryProgressStore
Physical movement = false

Очікуваний результат: composition root однозначно безапаратний.

Критерій правильності: невідомий ключ або dry_run=false відхиляється loader-ом.

Якщо результат не отримано: припиніть запуск; не підставляйте hardware fallback за замовчуванням.

Крок 2. Запустити повний dry-run

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

Python
# safe-smoke
from __future__ import annotations

import json
import math
from dataclasses import asdict, dataclass
from enum import Enum
from pathlib import Path
from typing import Protocol


class PalletizerError(RuntimeError):
    pass


class RunStatus(str, Enum):
    READY = "READY"
    PAUSED_RECONCILIATION_REQUIRED = "PAUSED_RECONCILIATION_REQUIRED"
    RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
    ERROR_RECONCILIATION_REQUIRED = "ERROR_RECONCILIATION_REQUIRED"
    COMPLETE = "COMPLETE"


@dataclass(frozen=True)
class Pose:
    x: float
    y: float
    z: float
    r: float


@dataclass(frozen=True)
class Cell:
    index: int
    row: int
    column: int
    place: Pose
    approach: Pose


@dataclass(frozen=True)
class Config:
    profile: str
    rows: int
    columns: int
    pitch_x: float
    pitch_y: float
    clearance: float
    pick: Pose
    base: Pose
    envelope: dict[str, tuple[float, float]]


@dataclass
class Progress:
    next_index: int
    placed_indices: list[int]
    status: str
    last_error: str | None = None


class RobotAdapter(Protocol):
    def plan(self, kind: str, pose: Pose) -> None: ...
    def stop(self, reason: str) -> None: ...


class GripperAdapter(Protocol):
    def close(self) -> None: ...
    def open(self) -> None: ...
    def stop(self, reason: str) -> None: ...


class ProgressStore(Protocol):
    def load(self) -> Progress | None: ...
    def save(self, progress: Progress) -> None: ...


def _pose(value: object) -> Pose:
    if not isinstance(value, list) or len(value) != 4:
        raise PalletizerError("pose має містити 4 числа")
    if not all(isinstance(item, (int, float)) and math.isfinite(item) for item in value):
        raise PalletizerError("pose має бути скінченною")
    return Pose(*(float(item) for item in value))


def parse_config_text(text: str) -> Config:
    raw = json.loads(text)
    expected = {"profile", "dry_run", "units", "frame", "rows", "columns", "pitch_x_mm", "pitch_y_mm", "clearance_mm", "pick_pose", "pallet_base_pose", "envelope_mm"}
    if not isinstance(raw, dict) or set(raw) != expected:
        raise PalletizerError("неправильна schema config")
    if raw["profile"] != "SYNTHETIC_PALLETIZER_DRY_RUN" or raw["dry_run"] is not True:
        raise PalletizerError("дозволено лише dry-run profile")
    if raw["units"] != "mm" or raw["frame"] != "SYNTHETIC_USER":
        raise PalletizerError("невідомі synthetic units/frame")
    if not isinstance(raw["rows"], int) or not isinstance(raw["columns"], int) or not 1 <= raw["rows"] <= 20 or not 1 <= raw["columns"] <= 20:
        raise PalletizerError("rows/columns поза межею")
    for key in ("pitch_x_mm", "pitch_y_mm", "clearance_mm"):
        if not isinstance(raw[key], (int, float)) or not math.isfinite(raw[key]) or raw[key] <= 0:
            raise PalletizerError(f"{key} має бути додатним")
    axes = {"x", "y", "z", "r_deg"}
    if not isinstance(raw["envelope_mm"], dict) or set(raw["envelope_mm"]) != axes:
        raise PalletizerError("неповний envelope")
    envelope: dict[str, tuple[float, float]] = {}
    for axis, limits in raw["envelope_mm"].items():
        if not isinstance(limits, list) or len(limits) != 2 or limits[0] >= limits[1]:
            raise PalletizerError(f"невалідна межа {axis}")
        envelope[axis] = (float(limits[0]), float(limits[1]))
    return Config(raw["profile"], raw["rows"], raw["columns"], float(raw["pitch_x_mm"]), float(raw["pitch_y_mm"]), float(raw["clearance_mm"]), _pose(raw["pick_pose"]), _pose(raw["pallet_base_pose"]), envelope)


def load_config(path: Path) -> Config:
    return parse_config_text(path.read_text(encoding="utf-8"))


def check_pose(pose: Pose, config: Config) -> None:
    for axis, value in {"x": pose.x, "y": pose.y, "z": pose.z, "r_deg": pose.r}.items():
        low, high = config.envelope[axis]
        if not low <= value <= high:
            raise PalletizerError(f"{axis} поза synthetic envelope")


def above(pose: Pose, clearance: float) -> Pose:
    return Pose(pose.x, pose.y, pose.z + clearance, pose.r)


def generate_cells(config: Config) -> list[Cell]:
    cells: list[Cell] = []
    for row in range(config.rows):
        for column in range(config.columns):
            place = Pose(config.base.x + column * config.pitch_x, config.base.y + row * config.pitch_y, config.base.z, config.base.r)
            approach = above(place, config.clearance)
            check_pose(place, config)
            check_pose(approach, config)
            cells.append(Cell(len(cells), row, column, place, approach))
    if len({(cell.place.x, cell.place.y, cell.place.z, cell.place.r) for cell in cells}) != len(cells):
        raise PalletizerError("duplicate cell pose")
    return cells


class MockRobot:
    def __init__(self, fail_after_plans: int | None = None) -> None:
        self.plans: list[tuple[str, Pose]] = []
        self.stopped = False
        self.fail_after_plans = fail_after_plans

    def plan(self, kind: str, pose: Pose) -> None:
        if self.stopped:
            raise PalletizerError("mock robot stopped")
        if kind not in {"JOINT_PLAN", "LINEAR_PLAN"}:
            raise PalletizerError("невідомий mock plan kind")
        if self.fail_after_plans is not None and len(self.plans) >= self.fail_after_plans:
            self.stop("injected robot planning fault")
            raise PalletizerError("injected robot planning fault")
        self.plans.append((kind, pose))

    def stop(self, reason: str) -> None:
        self.stopped = True


class MockGripper:
    def __init__(self, available_objects: int) -> None:
        self.available_objects = available_objects
        self.holding = False
        self.stopped = False

    def close(self) -> None:
        if self.stopped:
            raise PalletizerError("mock gripper stopped")
        if self.available_objects <= 0:
            self.stop("object not confirmed")
            raise PalletizerError("object not confirmed")
        self.available_objects -= 1
        self.holding = True

    def open(self) -> None:
        if self.stopped or not self.holding:
            raise PalletizerError("release without confirmed object")
        self.holding = False

    def stop(self, reason: str) -> None:
        self.stopped = True
        self.holding = False


class MemoryProgressStore:
    def __init__(self) -> None:
        self.payload: str | None = None

    def load(self) -> Progress | None:
        if self.payload is None:
            return None
        raw = json.loads(self.payload)
        return Progress(raw["next_index"], list(raw["placed_indices"]), raw["status"], raw["last_error"])

    def save(self, progress: Progress) -> None:
        self.payload = json.dumps(asdict(progress), sort_keys=True)


class Palletizer:
    def __init__(self, config: Config, robot: RobotAdapter, gripper: GripperAdapter, store: ProgressStore) -> None:
        self.config = config
        self.cells = generate_cells(config)
        self.robot = robot
        self.gripper = gripper
        self.store = store
        self.journal: list[dict[str, object]] = []
        saved = store.load()
        if saved is None:
            self.progress = Progress(0, [], RunStatus.READY.value)
        elif saved.status == RunStatus.COMPLETE.value:
            self.progress = saved
        else:
            saved.status = RunStatus.RECONCILIATION_REQUIRED.value
            self.progress = saved
            self.store.save(self.progress)

    def reconcile(self, observed_placed: list[int], *, operator_ack: bool) -> None:
        if self.progress.status != RunStatus.RECONCILIATION_REQUIRED.value:
            raise PalletizerError("reconciliation зараз не очікується")
        if not operator_ack or observed_placed != self.progress.placed_indices:
            raise PalletizerError("observed map не збігається або немає ack")
        self.progress.status = RunStatus.READY.value
        self.progress.last_error = None
        self.store.save(self.progress)
        self.journal.append({"event": "RECONCILED", "next_index": self.progress.next_index})

    def _run_cell(self, cell: Cell) -> None:
        pick_approach = above(self.config.pick, self.config.clearance)
        for pose in (pick_approach, self.config.pick, pick_approach, cell.approach, cell.place, cell.approach):
            check_pose(pose, self.config)
        self.robot.plan("JOINT_PLAN", pick_approach)
        self.robot.plan("LINEAR_PLAN", self.config.pick)
        self.gripper.close()
        self.robot.plan("LINEAR_PLAN", pick_approach)
        self.robot.plan("JOINT_PLAN", cell.approach)
        self.robot.plan("LINEAR_PLAN", cell.place)
        self.gripper.open()
        self.robot.plan("LINEAR_PLAN", cell.approach)

    def run(self, *, max_cells: int | None = None) -> Progress:
        if self.progress.status != RunStatus.READY.value:
            raise PalletizerError("run заблоковано до reconciliation")
        completed_now = 0
        while self.progress.next_index < len(self.cells):
            cell = self.cells[self.progress.next_index]
            try:
                self._run_cell(cell)
            except PalletizerError as exc:
                self.robot.stop(str(exc))
                self.gripper.stop(str(exc))
                self.progress.status = RunStatus.ERROR_RECONCILIATION_REQUIRED.value
                self.progress.last_error = str(exc)
                self.store.save(self.progress)
                self.journal.append({"event": "ERROR", "cell": cell.index, "reason": str(exc)})
                raise
            self.progress.placed_indices.append(cell.index)
            self.progress.next_index += 1
            completed_now += 1
            self.journal.append({"event": "MOCK_PLACED", "cell": cell.index})
            self.store.save(self.progress)
            if max_cells is not None and completed_now >= max_cells and self.progress.next_index < len(self.cells):
                self.progress.status = RunStatus.PAUSED_RECONCILIATION_REQUIRED.value
                self.store.save(self.progress)
                return self.progress
        self.progress.status = RunStatus.COMPLETE.value
        self.store.save(self.progress)
        self.journal.append({"event": "DRY_RUN_COMPLETE", "cells": len(self.cells)})
        return self.progress

    def journal_jsonl(self) -> str:
        return "\n".join(json.dumps(event, sort_keys=True) for event in self.journal)


fixture = {
    "profile": "SYNTHETIC_PALLETIZER_DRY_RUN",
    "dry_run": True,
    "units": "mm",
    "frame": "SYNTHETIC_USER",
    "rows": 2,
    "columns": 2,
    "pitch_x_mm": 20.0,
    "pitch_y_mm": 25.0,
    "clearance_mm": 30.0,
    "pick_pose": [0.0, -40.0, 10.0, 0.0],
    "pallet_base_pose": [-20.0, 20.0, 10.0, 0.0],
    "envelope_mm": {"x": [-100.0, 100.0], "y": [-100.0, 100.0], "z": [0.0, 120.0], "r_deg": [-180.0, 180.0]},
}

config = parse_config_text(json.dumps(fixture))
store = MemoryProgressStore()
robot_a = MockRobot()
first = Palletizer(config, robot_a, MockGripper(available_objects=2), store)
paused = first.run(max_cells=2)
assert paused.next_index == 2 and paused.status == RunStatus.PAUSED_RECONCILIATION_REQUIRED.value

robot_b = MockRobot()
second = Palletizer(config, robot_b, MockGripper(available_objects=2), store)
try:
    second.run()
except PalletizerError as exc:
    assert "reconciliation" in str(exc)
else:
    raise AssertionError("auto-resume має бути заборонене")
try:
    second.reconcile([0], operator_ack=True)
except PalletizerError:
    pass
else:
    raise AssertionError("невірна observed map має бути відхилена")
second.reconcile([0, 1], operator_ack=True)
complete = second.run()
assert complete.status == RunStatus.COMPLETE.value and complete.placed_indices == [0, 1, 2, 3]
assert len(robot_a.plans) + len(robot_b.plans) == 24
assert "DRY_RUN_COMPLETE" in second.journal_jsonl()

fault_store = MemoryProgressStore()
fault_robot = MockRobot()
faulty = Palletizer(config, fault_robot, MockGripper(available_objects=0), fault_store)
try:
    faulty.run(max_cells=1)
except PalletizerError as exc:
    assert "not confirmed" in str(exc)
else:
    raise AssertionError("невдале захоплення має зупинити dry-run")
assert fault_robot.stopped and faulty.progress.status == RunStatus.ERROR_RECONCILIATION_REQUIRED.value

print(json.dumps({"result": "PASS", "cells": 4, "planned_poses": 24, "resumed_after_reconciliation": True, "hardware": False}, ensure_ascii=False))
Bash
python -m py_compile lab14_palletizer_dry_run.py
python lab14_palletizer_dry_run.py

Очікуваний результат: JSON result=PASS, cells=4, planned_poses=24, resumed_after_reconciliation=true, hardware=false.

Критерій правильності: code 0, no files/network, progress проходить JSON round-trip in-memory, а resume без reconciliation відхилено.

Якщо результат не отримано: не підключайте adapter-и; перевірте traceback, progress transition і кількість plan poses.

Крок 3. Перевірити параметричні cells і послідовність

Для 2×2 має бути 4 комірки. Кожна cell створює 6 plan poses: pick approach, pick, pick retract, pallet approach, place, pallet retract.

Text
4 cells × 6 plan poses = 24 mock poses
Physical moves = 0

Очікуваний результат: 24 записи mock-plans і 4 MOCK_PLACED після повного сценарію.

Критерій правильності: усі poses у synthetic envelope, комірки унікальні, progress [0,1,2,3].

Якщо результат не отримано: не виправляйте count вручну; перевірте генератор і _run_cell.

Крок 4. Перевірити pause/restart/reconciliation

Перший session завершує 2 cells і зберігає paused status. Другий session має блокувати run(), відхилити observed [0] і дозволити resume лише після exact [0,1] + ack.

Text
PAUSED_RECONCILIATION_REQUIRED
    -> process restart
RECONCILIATION_REQUIRED
    -> exact observed map + operator_ack
READY -> remaining dry-run -> COMPLETE

Очікуваний результат: автоматичного resume немає.

Критерій правильності: жоден plan не додається другим session до успішного reconcile.

Якщо результат не отримано: зробіть run() fail-closed для всіх status, крім READY; не прирівнюйте checkpoint до physical observation.

Крок 5. Перевірити відмови robot/gripper/store

Додайте injected MockRobot(fail_after_plans=3), corrupted progress JSON і duplicate placed index. Кожна невизначеність має зупиняти обидва adapter-и та вимагати reconciliation.

Bash
python lab14_palletizer_dry_run.py

Очікуваний результат: fault до mock place не збільшує next_index; last_error і ERROR event збережені.

Критерій правильності: 0 retries, 0 auto-clear, 0 пропущених/подвійних індексів.

Якщо результат не отримано: оновлюйте progress тільки після повного підтвердженого mock place; обгорніть межу cell у fail-closed error transition.

Крок 6. Оформити паспорт, журнал і метрики

ПолеРезультат
Robot adapterMockRobot, plan-only
Gripper adapterMockGripper, synthetic objects
ProgressJSON round-trip in memory
Resumeexact reconciliation + ack
JournalJSONL string in memory
Configexternal synthetic JSON / in-memory smoke representation
Hardwareне підключалося й не перевірялося

Очікуваний результат: артефакти відтворюють dry-run і явно не є hardware log.

Критерій правильності: hardware=false, жодного IP/pin/секрету чи заяви про фізично укладені деталі.

Якщо результат не отримано: знеособте journal, додайте synthetic labels і вилучіть двозначні поля.

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

ТипСценарійОчікування
позитивнийповний 2×2 dry-run4 cells, 24 plans, COMPLETE
позитивнийpause 2 + exact reconcileremaining cells complete
негативнийresume без reconcileвідхилено, 0 нових plans
негативнийobserved map не збігаєтьсяreconciliation відхилено
негативнийgripper без objectобидва stop, ERROR status
негативнийrobot injected faultprogress не просувається
граничний1×16 plans, одна cell
граничнийapproach рівно на envelopeприйнято; вище — відхилено

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

SessionCellPlans beforeGraspMock placeProgress nextStatusErrorAuto-retry
100
110
2reconcile0
220
230

Метрики: 100% cells у synthetic envelope; 6 plans/cell; 0 hardware calls; 0 auto-resume; 0 retry після невідомості; progress оновлено рівно раз після кожного mock place; 100% faults fail-closed.

[!WARNING] [ПОТРЕБУЄ ДОПРАЦЮВАННЯ: СХОВИЩЕ ПРОГРЕСУ ТА ФІЗИЧНА АПРОБАЦІЯ ЛР-14] Не підтверджено: формат/шлях/права/атомарність/backup/retention фактичного progress store на Raspberry Pi, процедура звірки фізичних комірок, робота adapter-ів, траєкторії та stop/recovery на MERC-I5; фізична апробація не дозволена. Що потрібно додати: затверджене локальне сховище без секретів із atomic write/locking, schema migration і corruption test; операційну процедуру reconciliation, фактичний profile/adapters, оцінку ризику, E-Stop check, локального оператора й журнал першого запуску. Як завершити: спершу протестувати store з fault injection без обладнання, потім read-only звірку стенда; лише за окремим дозволом — покроковий dry-run gateway/перший рух на мінімальній затвердженій швидкості, контроль pause/restart без auto-resume; додати датований журнал і вилучити block.

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

Див. загальні вимоги. Додатково подайте:

  1. composition diagram і adapter contracts;
  2. external synthetic JSON та повний код;
  3. generated cells/poses і 24-plan audit;
  4. progress snapshots до/після pause/restart/reconcile;
  5. JSONL-журнал позитивного й fault scenarios;
  6. таблицю негативних і граничних тестів;
  7. явну фразу «MG400, грипер і Raspberry Pi hardware I/O не використовувалися».

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

СкладоваЧасткаКритерії
Підготовка10%джерела, safety scope, external config, composition root
Реалізація40%adapters, generator, dry-run, progress, reconciliation, journal, stop
Перевірка25%full/pause/resume/fault/bounds тести, code 0, no I/O
Аналіз15%checkpoint vs physical state, exactly-once limits, recovery і ризики
Звіт10%повні logs/tables/passport, джерела й hardware status
Разом100%

Мережевий/фізичний запуск, auto-resume або підміна observed state checkpoint-ом є критичною помилкою.

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

  1. Навіщо composition root відокремлює concrete adapter-и?
  2. Чому checkpoint не доводить фізичне положення деталі?
  3. Що саме має перевіряти reconciliation?
  4. Коли progress дозволено збільшити?
  5. Чому timeout не дозволяє повторити Pick/Place автоматично?
  6. Як atomic store захищає від часткового запису?
  7. Чому dry-run із 100% PASS не доводить collision safety?
  8. Який найменший пакет даних потрібний для hardware composition root?

Висновки

ЛР-14 завершується parameterized Python dry-run із mock robot/gripper adapter-ами, external synthetic profile, progress store, reconciliation gate та in-memory JSONL journal. Повний цикл, pause/restart і fault cases автоматично перевіряються без файлів, мережі й обладнання. Фактичні профілі, adapter-и, store та physical behavior залишаються адресно заблокованими до окремої валідації й дозволу.

MERCI SPACE