Лабораторна робота 22
Лабораторна робота №22. Розумне розвантаження палети
Мета: реалізувати наскрізний mock-сценарій розвантаження палети з QR MERC-I5 v1, транзакційним журналом, SQLite :memory: на основі codes/storage/schema.sql, ідемпотентністю, тайм-аутами та ручною звіркою після відмови або перезапуску; довести, що жодна неоднозначність не запускає наступний рух.
Результати навчання та передумови
Після роботи студент уміє:
- валідувати UTF-8 JSON QR v1 за схемою, розміром та ідентифікатором;
- завантажити затверджену SQL-схему в SQLite
:memory:; - виконувати дозволені стани
ON_PALLET → ... → STOREDу транзакціях; - забезпечити ідемпотентність повторної доставки події;
- відрізнити повтор того самого payload від конфлікту того самого
id; - переводити незавершений цикл у
RECOVERY_REQUIREDпісля restart; - перевірити 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 | не використовується | Робот, камера, конвеєр і сенсор не запускаються |
Необхідні знання та матеріали
codes/storage/schema.sql— єдине джерело таблиць і обмежень;- формат QR MERC-I5 v1;
- Python зі стандартною бібліотекою
sqlite3; - fixtures із цієї роботи без персональних, мережевих або секретних даних;
- політика допуску.
Ризики та правила безпеки
Ризики
Повтор повідомлення може дублювати операцію; суперечливий 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. - Фізичні команди відсутні.
Хід виконання роботи
- Перевірити QR v1 safe-smoke.
- Підготувати fixtures.
- Завантажити
schema.sqlу:memory:і seed тестові сутності. - Реалізувати транзакційні переходи та ідемпотентне scan.
- Запустити позитивні, негативні й restart cases.
- Перевірити таблиці, події, помилки та метрики.
Методичні вказівки й теоретичні відомості
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 і ручної звірки.
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. Джерела
- QR і модель розумного складу MERC-I5;
- затверджена SQL-схема;
- Python
sqlite3: офіційний довідник; - офіційний опис SQLite та WAL;
- програма ЛР-22.
Виконання лабораторної роботи
Крок 1. Safe-smoke QR v1
# 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.
[
{"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.
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-перевірка
python lab22_warehouse.py --schema codes/storage/schema.sql --cases lab22-cases.json --out lab22-results.json
Перевірте вихідний JSON:
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 і всі ack | COMPLETED/STORED | cell empty, BIN-A occupied |
| позитивний | повтор same delivery | ідемпотентний COMPLETED | event count не зростає |
| негативний | same id, інший batch | ERROR | error row, no route |
| негативний | extra field | ERROR | payload rejected |
| негативний | camera lost | RECOVERY_REQUIRED | no scan/route |
| граничний | sensor timeout | RECOVERY_REQUIRED | cell не звільняється |
| граничний | restart after PICKED | RECOVERY_REQUIRED | no automatic resume |
Таблиці вимірювань і метрики
| Case | Cycle status | Workpiece state | Events | Errors | Cell empty | Destination | Причина |
|---|---|---|---|---|---|---|---|
| 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% |
Контрольні питання
- Чому UTF-8 розмір треба рахувати в байтах?
- Чим duplicate delivery відрізняється від conflicting payload?
- Навіщо
idempotency_keyмає бути унікальним? - Чому commit БД не доводить фізичне положення?
- Коли cell можна позначити порожньою?
- Чому restart переводить цикл у
RECOVERY_REQUIRED?
Висновки
Опишіть транзакційно підтверджений шлях станів, результати семи cases, доказ ідемпотентності, реакцію на restart та дані, потрібні для фізичного розвантаження.