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

Лабораторна робота 22

Лабораторна робота №22. Розумне розвантаження палети

Мета: реалізувати наскрізний mock-сценарій розвантаження палети з QR MERC-I5 v1, транзакційним журналом, SQLite :memory: на основі codes/storage/schema.sql, ідемпотентністю, тайм-аутами та ручною звіркою після відмови або перезапуску; довести, що жодна неоднозначність не запускає наступний рух.

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

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

  1. валідувати UTF-8 JSON QR v1 за схемою, розміром та ідентифікатором;
  2. завантажити затверджену SQL-схему в SQLite :memory:;
  3. виконувати дозволені стани ON_PALLET → ... → STORED у транзакціях;
  4. забезпечити ідемпотентність повторної доставки події;
  5. відрізнити повтор того самого payload від конфлікту того самого id;
  6. переводити незавершений цикл у RECOVERY_REQUIRED після restart;
  7. перевірити camera lost, invalid/conflicting QR і conveyor timeout без обладнання.

Передумови: ЛР-10, ЛР-12–14, ЛР-20–21, Python, JSON, SQL, транзакції та скінченні автомати.

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

СередовищеСтатусПримітка
Google ColabдозволеноПотрібні файли schema.sql і fixtures; БД тільки в пам'яті
Локальний ПКосновнеPython stdlib sqlite3, без мережі
Raspberry PiдозволеноЛише :memory: і mock; не записувати виробничу БД
СимуляціяосновнеСимволічні pallet/cell/route, логічні підтвердження
Фізичний комплекс MERC-I5не використовуєтьсяРобот, камера, конвеєр і сенсор не запускаються

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

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

Ризики

Повтор повідомлення може дублювати операцію; суперечливий QR — прив'язати неправильну деталь; частковий commit — розвести фізичний і цифровий стани; restart — залишити деталь у невідомій позиції. SQLite-транзакція захищає дані, але не доводить фізичне розташування.

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

  • не відкривати фізичні драйвери, камеру, MG400, GPIO чи конвеєр;
  • не змінювати schema.sql у межах ЛР;
  • не використовувати SQL-форматування рядків замість placeholders;
  • не призначати маршрут невалідному/непрочитаному QR;
  • не продовжувати незавершений цикл після restart без ручної reconciliation;
  • не записувати у QR персональні дані, IP, токени або паролі.

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

Camera lost, payload > 512 bytes, malformed JSON, невідома/додаткова властивість, invalid id, conflict того самого id, DB exception, conveyor/sensor timeout, restart або суперечливий стан. Mock-виходи STOP, цикл ERROR чи RECOVERY_REQUIRED.

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

Нових mock-goals немає; feeder/conveyor/robot/gripper символічно STOP; транзакція commit або rollback завершена; причина записана; стан виробу/циклу не продовжується автоматично.

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

  • database=:memory:; шлях до persistent DB відсутній.
  • SQL завантажується з codes/storage/schema.sql без редагування.
  • Увімкнено PRAGMA foreign_keys і перевірено значення.
  • QR-валідатор відхиляє зайві поля та payload > 512 bytes.
  • Події мають унікальний idempotency_key.
  • Restart case очікує RECOVERY_REQUIRED.
  • Фізичні команди відсутні.

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

  1. Перевірити QR v1 safe-smoke.
  2. Підготувати fixtures.
  3. Завантажити schema.sql у :memory: і seed тестові сутності.
  4. Реалізувати транзакційні переходи та ідемпотентне scan.
  5. Запустити позитивні, негативні й restart cases.
  6. Перевірити таблиці, події, помилки та метрики.

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

1. QR MERC-I5 v1

Payload — UTF-8 JSON максимум 512 байтів. Обов'язкові ключі schema, id, type; дозволений необов'язковий batch; schema має бути merc-i5.workpiece.v1; id відповідає ^[A-Z][A-Z0-9_-]{2,31}$. Додаткові поля відхиляються. Канонічне JSON-представлення (sort_keys, компактні separators) потрібне для порівняння змісту.

2. Транзакції та ідемпотентність

Оновлення стану й запис події мають бути атомарними. idempotency_key у таблиці events має UNIQUE, тому повторна доставка тієї самої логічної події не створює другого запису. Це не дає права повторно виконувати фізичну дію: перед нею потрібна звірка state/evidence.

3. Модель станів і restart

Основний шлях визначений у локальній моделі. Із робочого стану дозволений ERROR. Незавершений RUNNING після restart стає RECOVERY_REQUIRED; оператор має знайти фізичний виріб і створити окрему recovery-подію.

Текстовий опис рисунка: mock-robot послідовно змінює цифровий стан виробу від палети до контрольної позиції; QR-валідатор і SQLite-транзакція ідентифікують та маршрутизують його; сенсорне підтвердження дозволяє звільнити комірку, а будь-яка відмова веде до ERROR/RECOVERY_REQUIRED і ручної звірки.

Mermaid
flowchart TD
    LOAD["Завантажити pallet/cycle"] --> PICK["Mock PICKED"]
    PICK --> CAM["Mock AT_CAMERA"]
    CAM --> QR{"QR v1 валідний та узгоджений?"}
    QR -->|так| ID["IDENTIFIED + event у транзакції"]
    ID --> ROUTE["ROUTE_ASSIGNED"]
    ROUTE --> CONV["Mock ON_CONVEYOR"]
    CONV --> ACK{"Сенсор підтвердив?"}
    ACK -->|так| STORED["STORED + cell empty + cycle complete"]
    QR -->|ні| ERR["ERROR / STOP"]
    ACK -->|ні| REC["RECOVERY_REQUIRED / STOP"]
    PICK -->|restart| REC

Рис. 1. Транзакційний mock-потік розумного розвантаження палети

4. Джерела

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

Крок 1. Safe-smoke QR v1

Python
# safe-smoke
import json
import re

ID_RE = re.compile(r"^[A-Z][A-Z0-9_-]{2,31}$")

def validate(raw: str) -> dict:
    if len(raw.encode("utf-8")) > 512:
        raise ValueError("payload-too-large")
    data = json.loads(raw)
    if not isinstance(data, dict) or not {"schema", "id", "type"} <= data.keys():
        raise ValueError("missing-fields")
    if not set(data) <= {"schema", "id", "type", "batch"}:
        raise ValueError("extra-fields")
    if data["schema"] != "merc-i5.workpiece.v1" or not ID_RE.fullmatch(data["id"]):
        raise ValueError("invalid-schema-or-id")
    return data

valid = '{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","batch":"LAB-2026-01"}'
assert validate(valid)["id"] == "BARREL-0001"
for bad in ('{"schema":"x","id":"bad","type":"barrel"}',
            '{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","secret":"x"}'):
    try:
        validate(bad)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid QR accepted")
print("safe-smoke: QR v1 validation PASS")

Очікуваний результат: safe-smoke: QR v1 validation PASS.

Критерій правильності: валідний payload прийнято; invalid schema/id і extra field відхилено.

Якщо результат не отримано: не створювати запис БД; виправити валідатор.

Крок 2. Fixtures сценаріїв

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

JSON
[
  {"id":"normal","payload":{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","batch":"LAB-2026-01"},"camera":true,"sensor":true,"duplicate_delivery":false,"conflict":false,"restart":false},
  {"id":"duplicate-delivery","payload":{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","batch":"LAB-2026-01"},"camera":true,"sensor":true,"duplicate_delivery":true,"conflict":false,"restart":false},
  {"id":"conflicting-payload","payload":{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","batch":"OTHER"},"camera":true,"sensor":true,"duplicate_delivery":false,"conflict":true,"restart":false},
  {"id":"invalid-extra-field","payload":{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","secret":"forbidden"},"camera":true,"sensor":true,"duplicate_delivery":false,"conflict":false,"restart":false},
  {"id":"camera-lost","payload":{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","batch":"LAB-2026-01"},"camera":false,"sensor":true,"duplicate_delivery":false,"conflict":false,"restart":false},
  {"id":"sensor-timeout","payload":{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","batch":"LAB-2026-01"},"camera":true,"sensor":false,"duplicate_delivery":false,"conflict":false,"restart":false},
  {"id":"restart-mid-cycle","payload":{"schema":"merc-i5.workpiece.v1","id":"BARREL-0001","type":"barrel","batch":"LAB-2026-01"},"camera":true,"sensor":true,"duplicate_delivery":false,"conflict":false,"restart":true}
]

Очікуваний результат: сім незалежних cases із валідними/невалідними payload.

Критерій правильності: немає персональних/мережевих/секретних значень; secret є лише назвою забороненого тестового поля зі значенням-заповнювачем.

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

Крок 3. Повна SQLite :memory: реалізація

Збережіть як lab22_warehouse.py. Скрипт читає незмінну локальну схему через --schema.

Python
from __future__ import annotations

import argparse
import json
import re
import sqlite3
from contextlib import contextmanager
from dataclasses import asdict, dataclass
from pathlib import Path

ID_RE = re.compile(r"^[A-Z][A-Z0-9_-]{2,31}$")
ALLOWED = {"schema", "id", "type", "batch"}


@dataclass
class Outcome:
    case_id: str
    cycle_status: str
    workpiece_state: str
    events: int
    errors: int
    cell_empty: bool
    destination: str | None
    reason: str


@contextmanager
def transaction(con: sqlite3.Connection):
    con.execute("BEGIN IMMEDIATE")
    try:
        yield
    except Exception:
        con.rollback()
        raise
    else:
        con.commit()


def canonical(data: dict) -> str:
    return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))


def validate_payload(data: object) -> dict:
    if not isinstance(data, dict):
        raise ValueError("payload-not-object")
    if not {"schema", "id", "type"} <= set(data):
        raise ValueError("missing-fields")
    if not set(data) <= ALLOWED:
        raise ValueError("extra-fields")
    if data["schema"] != "merc-i5.workpiece.v1":
        raise ValueError("unknown-schema")
    if not isinstance(data["id"], str) or not ID_RE.fullmatch(data["id"]):
        raise ValueError("invalid-id")
    if not isinstance(data["type"], str) or not data["type"]:
        raise ValueError("invalid-type")
    if "batch" in data and not isinstance(data["batch"], str):
        raise ValueError("invalid-batch")
    if len(canonical(data).encode("utf-8")) > 512:
        raise ValueError("payload-too-large")
    return data


def open_memory(schema_path: Path) -> sqlite3.Connection:
    con = sqlite3.connect(":memory:", isolation_level=None)
    con.row_factory = sqlite3.Row
    con.executescript(schema_path.read_text(encoding="utf-8"))
    assert con.execute("PRAGMA foreign_keys").fetchone()[0] == 1
    return con


def seed(con: sqlite3.Connection, cycle_id: str) -> str:
    payload = {"schema":"merc-i5.workpiece.v1", "id":"BARREL-0001", "type":"barrel", "batch":"LAB-2026-01"}
    with transaction(con):
        con.execute("INSERT INTO workpieces(id,type,batch,payload_json,state) VALUES(?,?,?,?,?)",
                    (payload["id"], payload["type"], payload["batch"], canonical(payload), "ON_PALLET"))
        con.execute("INSERT INTO pallets(id,rows,columns) VALUES(?,?,?)", ("PALLET-A", 1, 1))
        con.execute("INSERT INTO pallet_cells(pallet_id,row_index,column_index,workpiece_id) VALUES(?,?,?,?)",
                    ("PALLET-A", 0, 0, payload["id"]))
        con.execute("INSERT INTO storage_locations(id,description) VALUES(?,?)", ("BIN-A", "Навчальне місце A"))
        con.execute("INSERT INTO routes(id,workpiece_type,destination_id,enabled) VALUES(?,?,?,1)",
                    (1, "barrel", "BIN-A"))
        con.execute("INSERT INTO cycles(id,pallet_id,status) VALUES(?,?,?)", (cycle_id, "PALLET-A", "RUNNING"))
    return payload["id"]


def event(con: sqlite3.Connection, key: str, cycle: str, item: str, kind: str, details: dict) -> bool:
    cur = con.execute(
        "INSERT OR IGNORE INTO events(idempotency_key,cycle_id,workpiece_id,event_type,details_json) VALUES(?,?,?,?,?)",
        (key, cycle, item, kind, canonical(details)),
    )
    return cur.rowcount == 1


def transition(con: sqlite3.Connection, cycle: str, item: str, expected: str, target: str, key: str) -> None:
    row = con.execute("SELECT state FROM workpieces WHERE id=?", (item,)).fetchone()
    if row is None or row["state"] != expected:
        raise RuntimeError(f"state-mismatch:{expected}->{target}")
    con.execute("UPDATE workpieces SET state=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (target, item))
    if not event(con, key, cycle, item, target, {"from":expected,"to":target}):
        raise RuntimeError("duplicate-transition-event")


def latch(con: sqlite3.Connection, cycle: str, item: str, status: str, code: str) -> None:
    with transaction(con):
        con.execute("UPDATE cycles SET status=? WHERE id=?", (status, cycle))
        if status == "ERROR":
            con.execute("UPDATE workpieces SET state='ERROR' WHERE id=?", (item,))
            con.execute("INSERT INTO errors(cycle_id,workpiece_id,code,message) VALUES(?,?,?,?)",
                        (cycle, item, code, code))
        event(con, f"{cycle}:fault:{code}", cycle, item, status, {"code":code})


def scan(con: sqlite3.Connection, cycle: str, data: dict, delivery: str) -> str:
    data = validate_payload(data)
    encoded = canonical(data)
    with transaction(con):
        row = con.execute("SELECT payload_json,state FROM workpieces WHERE id=?", (data["id"],)).fetchone()
        if row is None:
            raise RuntimeError("unknown-workpiece")
        if row["payload_json"] != encoded:
            raise RuntimeError("conflicting-payload")
        inserted = event(con, f"{cycle}:scan:{delivery}", cycle, data["id"], "QR_SCANNED", data)
        if row["state"] == "AT_CAMERA":
            con.execute("UPDATE workpieces SET state='IDENTIFIED' WHERE id=?", (data["id"],))
        elif row["state"] not in ("IDENTIFIED", "ROUTE_ASSIGNED"):
            raise RuntimeError("scan-state-invalid")
    return "NEW" if inserted else "IDEMPOTENT"


def run_case(schema: Path, case: dict) -> Outcome:
    con = open_memory(schema)
    cycle = f"CYCLE-{case['id']}"
    item = seed(con, cycle)
    reason = "ok"
    try:
        with transaction(con):
            transition(con, cycle, item, "ON_PALLET", "PICKED", f"{cycle}:picked")
        if bool(case["restart"]):
            latch(con, cycle, item, "RECOVERY_REQUIRED", "restart-mid-cycle")
        elif not bool(case["camera"]):
            latch(con, cycle, item, "RECOVERY_REQUIRED", "camera-lost")
        else:
            with transaction(con):
                transition(con, cycle, item, "PICKED", "AT_CAMERA", f"{cycle}:at-camera")
            first = scan(con, cycle, case["payload"], "message-1")
            if bool(case["duplicate_delivery"]):
                assert scan(con, cycle, case["payload"], "message-1") == "IDEMPOTENT"
            with transaction(con):
                transition(con, cycle, item, "IDENTIFIED", "ROUTE_ASSIGNED", f"{cycle}:route")
                destination = con.execute(
                    "SELECT destination_id FROM routes WHERE workpiece_type=? AND enabled=1", (case["payload"]["type"],)
                ).fetchone()
                if destination is None:
                    raise RuntimeError("route-not-found")
                transition(con, cycle, item, "ROUTE_ASSIGNED", "ON_CONVEYOR", f"{cycle}:conveyor")
            if not bool(case["sensor"]):
                latch(con, cycle, item, "RECOVERY_REQUIRED", "sensor-timeout")
            else:
                with transaction(con):
                    transition(con, cycle, item, "ON_CONVEYOR", "STORED", f"{cycle}:stored")
                    con.execute("UPDATE pallet_cells SET workpiece_id=NULL WHERE workpiece_id=?", (item,))
                    con.execute("UPDATE storage_locations SET occupied_by=? WHERE id=?", (item, destination["destination_id"]))
                    con.execute("UPDATE cycles SET status='COMPLETED',finished_at=CURRENT_TIMESTAMP WHERE id=?", (cycle,))
            reason = first.lower()
    except (ValueError, RuntimeError, sqlite3.DatabaseError) as exc:
        reason = str(exc)
        latch(con, cycle, item, "ERROR", reason)

    cycle_status = con.execute("SELECT status FROM cycles WHERE id=?", (cycle,)).fetchone()[0]
    state = con.execute("SELECT state FROM workpieces WHERE id=?", (item,)).fetchone()[0]
    events = con.execute("SELECT COUNT(*) FROM events").fetchone()[0]
    errors = con.execute("SELECT COUNT(*) FROM errors").fetchone()[0]
    cell_empty = con.execute("SELECT workpiece_id IS NULL FROM pallet_cells").fetchone()[0] == 1
    destination_row = con.execute("SELECT id FROM storage_locations WHERE occupied_by=?", (item,)).fetchone()
    outcome = Outcome(str(case["id"]), cycle_status, state, events, errors, cell_empty,
                      None if destination_row is None else destination_row["id"], reason)
    con.close()
    return outcome


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--schema", type=Path, required=True); p.add_argument("--cases", type=Path, required=True)
    p.add_argument("--out", type=Path, required=True); args = p.parse_args()
    cases = json.loads(args.cases.read_text(encoding="utf-8"))
    results = [run_case(args.schema, case) for case in cases]
    statuses = {r.case_id:r.cycle_status for r in results}
    assert statuses["normal"] == statuses["duplicate-delivery"] == "COMPLETED"
    assert statuses["conflicting-payload"] == statuses["invalid-extra-field"] == "ERROR"
    for key in ("camera-lost", "sensor-timeout", "restart-mid-cycle"):
        assert statuses[key] == "RECOVERY_REQUIRED"
    duplicate = next(r for r in results if r.case_id == "duplicate-delivery")
    normal = next(r for r in results if r.case_id == "normal")
    assert duplicate.events == normal.events
    args.out.write_text(json.dumps([asdict(r) for r in results], ensure_ascii=False, indent=2), encoding="utf-8")
    print("PASS: COMPLETED=2 ERROR=2 RECOVERY_REQUIRED=3")
    return 0


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

Очікуваний результат: PASS: COMPLETED=2 ERROR=2 RECOVERY_REQUIRED=3.

Критерій правильності: повтор message-1 не збільшує event count; cell empty і destination заповнені лише для completed cases.

Якщо результат не отримано: не змінювати schema.sql; знайти першу помилку state/transaction, перевірити rollback та placeholders.

Крок 4. Запуск і SQL-перевірка

Bash
python lab22_warehouse.py --schema codes/storage/schema.sql --cases lab22-cases.json --out lab22-results.json

Перевірте вихідний JSON:

Bash
python -m json.tool lab22-results.json

Очікуваний результат: сім outcomes, жодного файла БД на диску.

Критерій правильності: normal і duplicate-delivery узгоджені; conflict/invalid мають error row; restart не COMPLETED.

Якщо результат не отримано: перевірити шлях до схеми й PRAGMA foreign_keys; не переходити на persistent DB для обходу помилки.

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

Еталонний цикл має використовувати перевірену карту палети, robot/gripper adapter, фіксовану контрольну позицію, camera/QR adapter, транзакційний storage service, conveyor/sensor ack і ручну recovery-процедуру. Кожний фізичний крок виконується лише після попереднього commit і перевірки фактичного стану за затвердженою архітектурою.

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

ТипСценарійОчікуванняDB/безпечна реакція
позитивнийвалідний QR і всі ackCOMPLETED/STOREDcell empty, BIN-A occupied
позитивнийповтор same deliveryідемпотентний COMPLETEDevent count не зростає
негативнийsame id, інший batchERRORerror row, no route
негативнийextra fieldERRORpayload rejected
негативнийcamera lostRECOVERY_REQUIREDno scan/route
граничнийsensor timeoutRECOVERY_REQUIREDcell не звільняється
граничнийrestart after PICKEDRECOVERY_REQUIREDno automatic resume

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

CaseCycle statusWorkpiece stateEventsErrorsCell emptyDestinationПричина
normal
duplicate-delivery
conflicting-payload
invalid-extra-field
camera-lost
sensor-timeout
restart-mid-cycle

Метрики: completion/error/recovery rate, idempotency violations (має бути 0), orphan references (має бути 0), кількість подій на completed цикл та кількість станів, що потребують reconciliation.

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

Див. загальні вимоги. Додайте QR fixtures, посилання на незмінну схему, код, таблицю переходів, DB-результати кожного case, доказ ідемпотентності, rollback/error аналіз і фразу «використано тільки SQLite :memory:; фізичні модулі не запускалися».

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

СкладоваЧасткаЩо оцінюється
Підготовка10%QR-контракт, schema, safety-чекліст
Реалізація40%транзакції, states, idempotency, events/errors
Перевірка25%7 cases, constraints, restart/recovery
Аналіз15%цифрово-фізична узгодженість і метрики
Звіт10%відтворюваність, SQL/JSON докази, межі
Разом100%

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

  1. Чому UTF-8 розмір треба рахувати в байтах?
  2. Чим duplicate delivery відрізняється від conflicting payload?
  3. Навіщо idempotency_key має бути унікальним?
  4. Чому commit БД не доводить фізичне положення?
  5. Коли cell можна позначити порожньою?
  6. Чому restart переводить цикл у RECOVERY_REQUIRED?

Висновки

Опишіть транзакційно підтверджений шлях станів, результати семи cases, доказ ідемпотентності, реакцію на restart та дані, потрібні для фізичного розвантаження.

MERCI SPACE