From 77069748ee416e6be54c0587b3dc177d81ffc90e Mon Sep 17 00:00:00 2001 From: deploy Date: Sat, 4 Jul 2026 01:02:01 +0200 Subject: [PATCH] feat: enhance sorter simulation demo --- README.md | 189 ++++++------ docs/ARCHITECTURE.md | 71 +++++ docs/DEMO_SCRIPT.md | 69 +++++ docs/JURY_QA.md | 41 +++ docs/SCENARIOS.md | 49 +++ package-lock.json | 395 ++++++++++++++++++++++++- package.json | 6 +- src/App.tsx | 4 +- src/components/ClassificationPanel.tsx | 54 +++- src/components/CurrentItemPanel.tsx | 32 +- src/components/Dashboard.tsx | 4 +- src/components/EventLog.tsx | 48 ++- src/components/Header.tsx | 7 +- src/components/MetricsPanel.tsx | 1 + src/components/PidPanel.tsx | 58 ++-- src/components/ScenarioPanel.tsx | 5 +- src/components/SorterScene.tsx | 184 +++++++----- src/components/TimelinePanel.tsx | 39 +++ src/data/scenarios.test.ts | 31 ++ src/data/scenarios.ts | 24 ++ src/domain/classifier.test.ts | 40 +++ src/domain/metrics.ts | 1 + src/domain/pid.test.ts | 31 ++ src/domain/pid.ts | 8 +- src/domain/simulation.ts | 110 ++++++- src/domain/types.ts | 15 + src/styles.css | 334 +++++++++++++++++++++ 27 files changed, 1618 insertions(+), 232 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEMO_SCRIPT.md create mode 100644 docs/JURY_QA.md create mode 100644 docs/SCENARIOS.md create mode 100644 src/components/TimelinePanel.tsx create mode 100644 src/data/scenarios.test.ts create mode 100644 src/domain/classifier.test.ts create mode 100644 src/domain/pid.test.ts diff --git a/README.md b/README.md index 8ce5f03..a19f2fa 100644 --- a/README.md +++ b/README.md @@ -2,91 +2,20 @@ Инженерный frontend MVP для задачи хакатона OZON Tech: «Интеллектуальная роботизированная система сортировки товаров». -Приложение показывает 2D/SVG dashboard сортировочной линии: конвейер, рабочую зону 6000 x 10000 мм, зоны A/B/C/D, датчики, stop-gate, боковые толкатели, движение товаров, классификацию, маршрутизацию, state machine, метрики, PID-виджет и event log. +Проект показывает 2D/SVG dashboard сортировочной линии: рабочую зону 6000 x 10000 мм, конвейер шириной 500 мм, зоны A/B/C/D, roll-cage C/D, датчики, stop-gate, толкатели, классификацию, маршрутизацию, timeline цикла, PID-имитацию, метрики и event log. -## Архитектура решения +## Стек -- Vite + React + TypeScript. -- Доменная логика вынесена в `src/domain`. -- Mock-данные товаров и сценариев находятся в `src/data`. -- UI собран из независимых панелей в `src/components`. -- Визуализация линии выполнена на SVG без Three.js и тяжелой 3D-графики. +- Vite +- React +- TypeScript +- SVG/CSS для инженерной 2D-сцены +- Vitest для доменных тестов +- Docker + nginx для production static hosting -## Логика классификации +Нет Three.js, backend, реального ML и тяжелых UI-библиотек. -Классификация реализована чистой функцией `classifyItem`. - -1. Сначала проверяются допустимые габариты. -2. Если товар нарушает min/max размеры, он относится к C. -3. Если габариты подходят, проверяется `roundness`. -4. Если `roundness >= 0.8`, товар относится к D. -5. Иначе товар относится к B. -6. Если объект одновременно негабаритный и круглый, приоритет остается у C. - -Допустимые габариты MVP: - -- min: width >= 10 мм, depth >= 10 мм, height >= 2 мм; -- max: width <= 450 мм, depth <= 320 мм, height <= 320 мм; -- roundness threshold: 0.8. - -## Исполнительная часть - -Симуляция отображает: - -- камеру с pseudo-CV, bounding box и confidence; -- лазерный датчик высоты; -- ультразвуковой датчик прибытия к stop-gate; -- stop-gate с состояниями open/closed; -- pusher C и pusher D со состояниями idle/extended/retracting; -- маршруты B/C/D с цветовой индикацией; -- предупреждения для close items и low confidence; -- fault/emergency сценарии с остановкой конвейера. - -## State machine - -Состояния MVP: - -- IDLE -- MOVING_TO_CAMERA -- DETECTING -- MOVING_TO_GATE -- WAITING_AT_GATE -- CLASSIFYING -- ROUTE_TO_B -- ROUTE_TO_C -- ROUTE_TO_D -- RETURN_HOME -- FAULT -- EMERGENCY_STOP - -## Сценарии - -- `normal_flow` — обычный поток товаров B/C/D. -- `oversized_item` — товар выходит за max dimensions и уходит в C. -- `round_object` — товар проходит по габаритам, но имеет roundness >= 0.8 и уходит в D. -- `boundary_dimensions` — товары на границах допустимых размеров. -- `close_items` — два товара близко друг к другу, появляется warning queue/spacing. -- `low_confidence` — confidence ниже 0.65, решение принимается rule-based. -- `jam` — застревание у stop-gate, state FAULT, conveyor stopped. -- `emergency_stop` — аварийная остановка, state EMERGENCY_STOP. - -## Метрики - -Dashboard показывает: - -- processed count; -- success count; -- error count; -- avg cycle time; -- throughput items/min; -- cv latency; -- actuator latency; -- queue length; -- conveyor speed; -- PID target speed; -- PID actual speed. - -## Локальный запуск +## Запуск локально ```bash npm install @@ -95,7 +24,7 @@ npm run dev Открыть: http://127.0.0.1:3100/ -## Запуск на сервере +## Запуск через Docker ```bash docker compose -p owl -f docker-compose.server.yml up -d --build @@ -107,25 +36,97 @@ Compose публикует только loopback-порт: 127.0.0.1:3100:80 ``` -## Как проверить +## Проверка домена ```bash curl -I http://127.0.0.1:3100/ +curl -I https://arhipovdan.ru/ +curl -I https://www.arhipovdan.ru/ ``` +## Сценарии + +- `normal_flow` — обычный поток B/C/D. +- `oversized_item` — max dimensions нарушены, маршрут C. +- `round_object` — габариты проходят, roundness >= 0.8, маршрут D. +- `boundary_dimensions` — проверка min/max границ. +- `close_items` — предупреждение spacing/queue, последовательная обработка. +- `low_confidence` — низкий CV confidence, rule-based fallback. +- `jam` — застревание у gate, FAULT, остановка конвейера. +- `emergency_stop` — EMERGENCY_STOP, остановка всех движений. + +## Классификация + +Классификация реализована чистой функцией `classifyItem`. + +1. Проверяются габариты. +2. Если нарушены min/max размеры, категория C. +3. Если габариты подходят, проверяется `roundness`. +4. Если `roundness >= 0.8`, категория D. +5. Иначе категория B. +6. Если товар одновременно негабаритный и круглый, приоритет у C, потому что dimensions check идет первым. + +Границы MVP: + +- min: width >= 10 мм, depth >= 10 мм, height >= 2 мм; +- max: width <= 450 мм, depth <= 320 мм, height <= 320 мм; +- roundness threshold: 0.8. + +## Исполнительная часть + +State machine управляет циклом: + +- `MOVING_TO_CAMERA` +- `DETECTING` +- `MOVING_TO_GATE` +- `WAITING_AT_GATE` +- `CLASSIFYING` +- `ROUTE_TO_B/C/D` +- `RETURN_HOME` +- `FAULT` +- `EMERGENCY_STOP` + +Датчики имитируются по mock-данным: camera bbox/confidence/CV latency, laser measured height, ultrasonic gate detection. Stop-gate закрывается перед классификацией, открывается для B и удерживает товар для C/D перед толкателями. + +## Simplified PID + +PID-панель показывает упрощенную имитацию control loop: + +- target speed; +- actual speed; +- error; +- correction; +- mini graph последних тиков скорости. + +В normal flow actual speed приближается к target. В `jam` и `emergency_stop` target становится 0, actual speed визуально падает к 0. + +## Тесты + +```bash +npm run test +``` + +Покрыты classifier, PID и целостность сценариев. + +## Документация + +- `docs/ARCHITECTURE.md` — модули и поток данных. +- `docs/DEMO_SCRIPT.md` — 3-5 минутный сценарий защиты. +- `docs/SCENARIOS.md` — ожидаемые результаты сценариев. +- `docs/JURY_QA.md` — короткие ответы на вопросы жюри. + ## Ограничения MVP -- Физика движения упрощена до дискретной state-machine симуляции. -- PID-регулятор имитационный, без точной модели двигателя и нагрузки. -- CV, laser и ultrasound генерируют псевдоизмерения на основе mock-данных. -- Нет backend, real-time API и сохранения событий. -- Нет Three.js и полноценного digital twin в 3D. +- Физика движения дискретная, без динамической модели массы/трения. +- CV является pseudo-CV по mock-данным. +- PID упрощен до демонстрации стабилизации скорости. +- Нет backend, real-time API, сохранения событий и реального ML. +- Нет 3D digital twin. ## Что улучшить дальше -- Добавить backend с WebSocket-телеметрией. -- Подключить реальные ML/CV модели или replay датасетов. -- Сделать режим ручного управления исполнительными механизмами. -- Добавить тесты доменной логики и сценариев. -- Расширить физическую модель очереди, зазоров и jam recovery. -- Добавить экспорт event log и отчеты по SLA сортировки. +- Добавить WebSocket-телеметрию и replay реальных событий. +- Подключить реальные CV-модели или датасеты. +- Добавить режим manual override для gate/pushers. +- Расширить модель очереди, spacing и recovery после jam. +- Экспортировать event log в отчет смены. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..035ef82 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,71 @@ +# Architecture + +## Module Map + +```text +src/data/items.ts +src/data/scenarios.ts + | + v +src/domain/classifier.ts -> src/domain/simulation.ts -> React UI components + | | + | +-> metrics, PID, sensors, event log + v +classification result +``` + +## Data Flow + +1. Scenario selects a sequence of mock items. +2. Simulation feeds one item at a time into zone A. +3. Pseudo-CV and sensors derive measurements from item data. +4. `classifyItem` applies deterministic rules. +5. State machine commands gate and pushers. +6. UI renders SVG scene, panels, metrics, timeline and event log. + +## Pseudo-CV + +The MVP does not run real ML. Camera output is generated from item dimensions: + +- bbox width/depth; +- confidence; +- CV latency; +- detected dimensions. + +Low confidence is not ignored: the event log and classification panel show a warning and explain rule-based fallback. + +## Sensor Simulation + +- Camera is active in `DETECTING`. +- Laser is active during `DETECTING` and `MOVING_TO_GATE`. +- Ultrasonic sensor is active at `WAITING_AT_GATE` and `CLASSIFYING`. + +Each sensor keeps active state, last value, latency and last event timestamp. + +## Actuator Control + +- Stop-gate closes at `WAITING_AT_GATE`. +- B route opens the gate and sends item straight. +- C route keeps the gate closed and extends pusher C. +- D route keeps the gate closed and extends pusher D. +- `RETURN_HOME` retracts mechanisms. + +## State Machine + +Main cycle: + +```text +IDLE -> MOVING_TO_CAMERA -> DETECTING -> MOVING_TO_GATE -> WAITING_AT_GATE +-> CLASSIFYING -> ROUTE_TO_B/C/D -> RETURN_HOME -> next item or IDLE +``` + +Fault states: + +- `FAULT` for jam at gate; +- `EMERGENCY_STOP` for emergency stop scenario. + +Both stop conveyor motion and require Reset. + +## Metrics + +The dashboard tracks processed count, success/error count, avg cycle time, throughput, CV latency, actuator latency, queue length, queue delay and conveyor speed. diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md new file mode 100644 index 0000000..dc56fc6 --- /dev/null +++ b/docs/DEMO_SCRIPT.md @@ -0,0 +1,69 @@ +# Demo Script + +Target duration: 3-5 minutes. + +## 1. Open The Dashboard + +Open https://arhipovdan.ru/. + +Explain that this is a 2D engineering simulation, not a decorative animation: the SVG scene shows a scaled work zone, conveyor dimensions, sensors, stop-gate, pushers and roll-cages. + +## 2. Normal Flow + +Select `Normal flow`, press `Start`. + +Explain: + +- item enters zone A; +- camera captures bbox; +- laser measures height; +- ultrasonic confirms gate position; +- gate holds item; +- classifier selects B/C/D; +- route command is shown on the scene; +- event log records the full cycle. + +Question closed: can the system show a full sorting cycle end-to-end? + +## 3. Step-By-Step Decision + +Press `Reset`, then use `Step state`. + +Explain each state in the timeline. Show that Step advances by logical state, not by arbitrary animation time. + +Question closed: can the jury inspect synchronization and state transitions? + +## 4. Oversized And Round Rules + +Select `Oversized item`, step to classification. + +Show decision tree: + +- dimensions check fails; +- category C selected; +- roundness is lower priority because dimensions are checked first. + +Then select `Round object` and show D when dimensions pass and roundness >= 0.8. + +Question closed: how is classification proved? + +## 5. Robustness Scenarios + +Show `close_items`, `low_confidence`, `jam`, `emergency_stop`. + +Explain: + +- close items generate spacing warning and queue length; +- low confidence uses rule-based fallback; +- jam enters FAULT and stops conveyor; +- emergency stop enters EMERGENCY_STOP and requires Reset. + +Question closed: what happens outside the happy path? + +## 6. PID And Metrics + +Point to PID panel and metrics cards. + +Explain that PID is simplified: actual speed approaches target in normal flow and decays toward zero in fault/emergency. + +Question closed: how is conveyor control represented without overbuilding physics? diff --git a/docs/JURY_QA.md b/docs/JURY_QA.md new file mode 100644 index 0000000..749f0de --- /dev/null +++ b/docs/JURY_QA.md @@ -0,0 +1,41 @@ +# Jury Q&A + +## 1. Why 2D, Not 3D? + +2D is enough for MVP validation: it shows geometry, timing, sensor positions, routes and state transitions without spending effort on heavy rendering. + +## 2. Where Is Computer Vision? + +The MVP uses pseudo-CV: bbox, dimensions, confidence and latency are derived from mock items. The architecture keeps CV output separate from classification, so a real CV service can replace it later. + +## 3. How Is Classification Correctness Proven? + +The decision tree shows PASS/FAIL for dimensions and roundness, actual values, thresholds and final category. Tests cover key boundary cases. + +## 4. How Are Dimensions And Circular Section Handled? + +Dimensions are checked first against min/max width, depth and height. If they pass, roundness is checked against threshold 0.8. + +## 5. Why Does C Have Priority Over D? + +Oversized or undersized items are operationally unsafe for the main line and must be diverted first. Therefore dimensions check precedes roundness. + +## 6. How Is Synchronization Shown? + +Cycle timeline shows state order, simulated timestamps, durations and status: done, active, pending or skipped. + +## 7. How Does The Actuator Part Work? + +The stop-gate fixes the item. B opens the gate, C extends pusher C, D extends pusher D, then mechanisms return home. + +## 8. What Happens On Jam? + +The system enters FAULT, conveyor target speed becomes 0, actual speed decays toward 0, and Reset is required. + +## 9. What Happens On Low CV Confidence? + +A warning is logged, but the system still classifies by deterministic dimensions and roundness rules. + +## 10. How To Scale This To A Real Hardware-Software System? + +Replace pseudo-CV with a CV service, connect PLC/robot telemetry via backend/WebSocket, persist event logs, calibrate sensor latencies and add recovery policies. diff --git a/docs/SCENARIOS.md b/docs/SCENARIOS.md new file mode 100644 index 0000000..a00d048 --- /dev/null +++ b/docs/SCENARIOS.md @@ -0,0 +1,49 @@ +# Scenarios + +## normal_flow + +Goal: demonstrate the full cycle across B/C/D routes. + +Expected result: mixed B, C and D items are processed successfully. + +## oversized_item + +Goal: prove dimensions have first priority. + +Expected result: every item routes to C. + +## round_object + +Goal: prove roundness check after dimensions. + +Expected result: every item routes to D. + +## boundary_dimensions + +Goal: test min/max boundaries. + +Expected result: `Boundary box 450x320x320` routes to B, `Pen 9x13x148` routes to C. + +## close_items + +Goal: demonstrate queue/spacing resilience. + +Expected result: warning appears, queue length is shown, items are processed sequentially. + +## low_confidence + +Goal: demonstrate fallback when pseudo-CV confidence is below 0.65. + +Expected result: warning appears, rule-based classification still selects B or D. + +## jam + +Goal: demonstrate fail-safe behavior at stop-gate. + +Expected result: state becomes FAULT, conveyor speed target is 0, Reset is required. + +## emergency_stop + +Goal: demonstrate emergency stop. + +Expected result: state becomes EMERGENCY_STOP, conveyor speed target is 0, Reset is required. diff --git a/package-lock.json b/package-lock.json index 8feefda..fa008e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "devDependencies": { "@types/node": "^26.1.0", "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3" + "@types/react-dom": "^19.2.3", + "vitest": "^4.1.9" } }, "node_modules/@emnapi/core": { @@ -51,6 +52,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -326,6 +334,13 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -336,6 +351,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", @@ -391,6 +431,146 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -407,6 +587,33 @@ "node": ">=8" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -687,6 +894,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -705,6 +922,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -811,6 +1049,13 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -820,6 +1065,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -836,6 +1112,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -939,6 +1225,113 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 23ab5f1..f4632e1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite --host 127.0.0.1 --port 3100", "build": "tsc -b && vite build", - "preview": "vite preview --host 127.0.0.1 --port 3100" + "preview": "vite preview --host 127.0.0.1 --port 3100", + "test": "vitest run" }, "dependencies": { "@vitejs/plugin-react": "latest", @@ -18,6 +19,7 @@ "devDependencies": { "@types/node": "^26.1.0", "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3" + "@types/react-dom": "^19.2.3", + "vitest": "^4.1.9" } } diff --git a/src/App.tsx b/src/App.tsx index 335aa4a..a1c8932 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import Dashboard from './components/Dashboard'; import { SCENARIOS } from './data/scenarios'; -import { createSimulation, setRunning, stepSimulation } from './domain/simulation'; +import { createSimulation, setRunning, stepSimulation, stepSimulationToNextState } from './domain/simulation'; import type { ScenarioId, SimulationState } from './domain/types'; const TICK_MS = 250; @@ -33,7 +33,7 @@ export default function App() { const handleStart = () => setSimulation((current) => setRunning(current, true)); const handlePause = () => setSimulation((current) => setRunning(current, false)); const handleReset = () => setSimulation(createSimulation(activeScenario)); - const handleStep = () => setSimulation((current) => stepSimulation(setRunning(current, false), 500, true)); + const handleStep = () => setSimulation((current) => stepSimulationToNextState(current)); const handleScenarioChange = (scenarioId: ScenarioId) => setActiveScenarioId(scenarioId); return ( diff --git a/src/components/ClassificationPanel.tsx b/src/components/ClassificationPanel.tsx index 2e5af88..64ea8d7 100644 --- a/src/components/ClassificationPanel.tsx +++ b/src/components/ClassificationPanel.tsx @@ -1,26 +1,48 @@ import { DIMENSION_LIMITS } from '../domain/classifier'; -import type { SimulatedItem } from '../domain/types'; +import type { SimulationState } from '../domain/types'; -export default function ClassificationPanel({ currentItem }: { currentItem?: SimulatedItem }) { - const result = currentItem?.classification; +function Verdict({ pass, skipped = false }: { pass?: boolean; skipped?: boolean }) { + if (skipped) { + return SKIPPED; + } + if (pass === undefined) { + return PENDING; + } + return {pass ? 'PASS' : 'FAIL'}; +} + +export default function ClassificationPanel({ simulation }: { simulation: SimulationState }) { + const current = simulation.currentItem; + const result = current?.classification; + const item = current?.item; + const dimensions = item?.dimensionsMm; + const isOversizedAndRound = Boolean(result && !result.dimensionsPass && !result.roundnessPass); + const lowConfidence = Boolean(item && item.confidence < 0.65); return ( -
+

Classification rules

-

Decision tree

+

Decision proof

+
+
+
+
Step 1: Dimensions check
+

Allowed: W {DIMENSION_LIMITS.min.width}-{DIMENSION_LIMITS.max.width} mm, D {DIMENSION_LIMITS.min.depth}-{DIMENSION_LIMITS.max.depth} mm, H {DIMENSION_LIMITS.min.height}-{DIMENSION_LIMITS.max.height} mm.

+

Actual: {dimensions ? `${dimensions.width} x ${dimensions.depth} x ${dimensions.height} mm` : 'waiting for item'}

+
+
+
Step 2: Roundness check
+

Roundness threshold for D: value >= {DIMENSION_LIMITS.roundnessThreshold}.

+

Actual roundness: {item ? item.roundness.toFixed(2) : 'waiting for item'}

+
+
+
Step 3: Category decision{result ? result.category : '-'}
+

{result ? result.reason : 'Run a scenario to classify the current item.'}

+ {isOversizedAndRound ?

Приоритет: C, потому что габариты проверяются первыми.

: null} + {lowConfidence ?

CV confidence низкий, решение подтверждено rule-based fallback.

: null} +
-
    -
  1. - dimensions check: {DIMENSION_LIMITS.min.width}-{DIMENSION_LIMITS.max.width} W, {DIMENSION_LIMITS.min.depth}-{DIMENSION_LIMITS.max.depth} D, {DIMENSION_LIMITS.min.height}-{DIMENSION_LIMITS.max.height} H -
  2. -
  3. - roundness check: threshold < {DIMENSION_LIMITS.roundnessThreshold} -
  4. -
  5. - result: {result ? `${result.category} - ${result.label}` : 'waiting for item'} -
  6. -
); } diff --git a/src/components/CurrentItemPanel.tsx b/src/components/CurrentItemPanel.tsx index cf8285f..dd9d0fa 100644 --- a/src/components/CurrentItemPanel.tsx +++ b/src/components/CurrentItemPanel.tsx @@ -2,6 +2,8 @@ import type { SimulationState } from '../domain/types'; export default function CurrentItemPanel({ simulation }: { simulation: SimulationState }) { const current = simulation.currentItem; + const camera = simulation.sensors.camera; + const laser = simulation.sensors.laser; return (
@@ -10,15 +12,27 @@ export default function CurrentItemPanel({ simulation }: { simulation: Simulatio

{current?.item.id ?? 'No active item'}

{current ? ( -
- Name{current.item.name} - Dimensions{current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} x {current.item.dimensionsMm.height} mm - Height{current.item.dimensionsMm.height} mm - Roundness{current.item.roundness.toFixed(2)} - Confidence{Math.round(current.item.confidence * 100)}% - Category{current.classification.category} / {current.classification.label} - Reason{current.classification.reason} -
+ <> +
+ Name{current.item.name} + Dimensions{current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} x {current.item.dimensionsMm.height} mm + Shape{current.item.shape} + Roundness{current.item.roundness.toFixed(2)} + Confidence{Math.round(current.item.confidence * 100)}% + Category{current.classification.category} / {current.classification.label} + Reason{current.classification.reason} +
+
+

Pseudo-CV / sensor packet

+
+ bbox{camera.bbox ? `${camera.bbox.width} x ${camera.bbox.height} mm` : 'pending'} + confidence{camera.confidence ? `${Math.round(camera.confidence * 100)}%` : 'pending'} + cv latency{camera.cvLatencyMs} ms + detected dimensions{current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} x {current.item.dimensionsMm.height} mm + measured height{laser.measuredHeightMm ?? current.item.dimensionsMm.height} mm +
+
+ ) : (

Start or step the simulation to feed an item into zone A.

)} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index a00f463..1b7da69 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -8,6 +8,7 @@ import EventLog from './EventLog'; import ScenarioPanel from './ScenarioPanel'; import SensorPanel from './SensorPanel'; import PidPanel from './PidPanel'; +import TimelinePanel from './TimelinePanel'; import type { Scenario, ScenarioId, SimulationState } from '../domain/types'; interface DashboardProps { @@ -46,12 +47,13 @@ export default function Dashboard({
+ ('all'); + const [copied, setCopied] = useState(false); + const visibleEvents = useMemo(() => events.filter((entry) => matchesFilter(entry, filter)), [events, filter]); + + const copyJson = async () => { + await navigator.clipboard.writeText(JSON.stringify(events, null, 2)); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }; + return (
-

Event log

-

Cycle trace

+
+

Event log

+

Cycle trace

+
+ +
+
+ {filters.map((item) => ( + + ))}
- timeitemtypemessagecategorystatus + timeitemstatetypecommand/categorymessagestatus
- {events.length === 0 ? ( -
No events yet. Press Start or Step.
- ) : events.map((entry) => ( + {visibleEvents.length === 0 ? ( +
No events for this filter.
+ ) : visibleEvents.map((entry) => (
{formatTime(entry.timestampMs)} {entry.itemId ?? '-'} + {entry.state ?? '-'} {entry.type} + {entry.command ?? entry.category ?? '-'} {entry.message} - {entry.category ?? '-'} {entry.status}
))} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6556e90..d694eb9 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -9,6 +9,8 @@ interface HeaderProps { } export default function Header({ simulation, onStart, onPause, onReset, onStep }: HeaderProps) { + const locked = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP'; + return (
@@ -21,11 +23,12 @@ export default function Header({ simulation, onStart, onPause, onReset, onStep } {simulation.systemStatus}
Scenario: {simulation.scenario.name}
+
Sim t+{(simulation.simTimeMs / 1000).toFixed(1)}s
- + - +
diff --git a/src/components/MetricsPanel.tsx b/src/components/MetricsPanel.tsx index b696f4c..7a3c281 100644 --- a/src/components/MetricsPanel.tsx +++ b/src/components/MetricsPanel.tsx @@ -9,6 +9,7 @@ const metricLabels: Array<[keyof Metrics, string, string]> = [ ['cvLatencyMs', 'CV latency', 'ms'], ['actuatorLatencyMs', 'Actuator latency', 'ms'], ['queueLength', 'Queue length', 'items'], + ['queueDelayMs', 'Queue delay', 'ms'], ['conveyorSpeedMps', 'Conveyor speed', 'm/s'], ['pidTargetSpeedMps', 'PID target', 'm/s'], ['pidActualSpeedMps', 'PID actual', 'm/s'], diff --git a/src/components/PidPanel.tsx b/src/components/PidPanel.tsx index dd8a8c4..b2b101a 100644 --- a/src/components/PidPanel.tsx +++ b/src/components/PidPanel.tsx @@ -6,37 +6,53 @@ interface PidPanelProps { status: SystemStatus; } +function pidState(pid: PidState, status: SystemStatus): string { + if (status === 'EMERGENCY_STOP') { + return 'emergency stop'; + } + if (status === 'FAULT') { + return 'stopped by fault'; + } + if (Math.abs(pid.pidError) <= 0.03 && pid.actualSpeedMps > 0) { + return 'stable'; + } + return 'stabilizing'; +} + +function sparkline(history: number[]): string { + const values = history.length > 1 ? history : [0, 0]; + const max = Math.max(...values, 0.5); + return values + .map((value, index) => { + const x = (index / Math.max(values.length - 1, 1)) * 220; + const y = 58 - (value / max) * 48; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); +} + export default function PidPanel({ pid, status }: PidPanelProps) { const fill = Math.min((pid.actualSpeedMps / Math.max(pid.targetSpeedMps, 0.01)) * 100, 100); + const state = pidState(pid, status); return (

Conveyor control loop

-

PID imitation

+

{state}

-
- targetSpeedMps - {pid.targetSpeedMps.toFixed(2)} -
-
- actualSpeedMps - {pid.actualSpeedMps.toFixed(2)} -
-
- pidError - {pid.pidError.toFixed(2)} -
-
- correction - {pid.correction.toFixed(2)} -
+
target speed{pid.targetSpeedMps.toFixed(2)} m/s
+
actual speed{pid.actualSpeedMps.toFixed(2)} m/s
+
error{pid.pidError.toFixed(2)}
+
correction{pid.correction.toFixed(2)}
-
-
-
-

Status {status}: actual speed follows target during flow and decays to zero on jam/emergency.

+
+ + + + +

PID shown as simplified control-loop simulation for conveyor speed stabilization.

); } diff --git a/src/components/ScenarioPanel.tsx b/src/components/ScenarioPanel.tsx index 2327ea1..18b4c46 100644 --- a/src/components/ScenarioPanel.tsx +++ b/src/components/ScenarioPanel.tsx @@ -11,7 +11,7 @@ export default function ScenarioPanel({ scenarios, activeScenarioId, onScenarioC

Scenario panel

-

Test cases

+

Jury test cases

{scenarios.map((scenario) => ( @@ -22,6 +22,9 @@ export default function ScenarioPanel({ scenarios, activeScenarioId, onScenarioC > {scenario.name} {scenario.description} + Goal: {scenario.goal} + Expected: {scenario.expectedCategorySummary} + Shows: {scenario.demonstrates} ))}
diff --git a/src/components/SorterScene.tsx b/src/components/SorterScene.tsx index 23b3e4a..88dba2f 100644 --- a/src/components/SorterScene.tsx +++ b/src/components/SorterScene.tsx @@ -8,15 +8,15 @@ const routeColors: Record = { function progressForState(state: MachineState, elapsedMs: number): number { const ratios: Partial> = { - MOVING_TO_CAMERA: [0.08, 0.34, 1200], + MOVING_TO_CAMERA: [0.06, 0.34, 1200], DETECTING: [0.34, 0.36, 900], MOVING_TO_GATE: [0.36, 0.64, 1300], WAITING_AT_GATE: [0.64, 0.65, 800], CLASSIFYING: [0.65, 0.66, 700], - ROUTE_TO_B: [0.66, 0.92, 1200], - ROUTE_TO_C: [0.66, 0.82, 1200], - ROUTE_TO_D: [0.66, 0.82, 1200], - RETURN_HOME: [0.92, 0.94, 700], + ROUTE_TO_B: [0.66, 0.93, 1200], + ROUTE_TO_C: [0.66, 0.78, 1200], + ROUTE_TO_D: [0.66, 0.78, 1200], + RETURN_HOME: [0.93, 0.94, 700], }; const segment = ratios[state]; if (!segment) { @@ -28,20 +28,42 @@ function progressForState(state: MachineState, elapsedMs: number): number { function itemPosition(simulation: SimulationState): { x: number; y: number } { const progress = progressForState(simulation.machineState, simulation.elapsedInStateMs); - const baseX = 120 + progress * 760; - const beltY = 315; + const baseX = 126 + progress * 790; + const beltY = 360; if (simulation.machineState === 'ROUTE_TO_C') { - return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 }; + return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 172 }; } if (simulation.machineState === 'ROUTE_TO_D') { - return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 }; + return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 172 }; } return { x: baseX, y: beltY }; } +function DimensionLine({ x1, y1, x2, y2, label }: { x1: number; y1: number; x2: number; y2: number; label: string }) { + const labelX = (x1 + x2) / 2; + const labelY = (y1 + y2) / 2; + return ( + + + + + {label} + + ); +} + +function LegendItem({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ); +} + export default function SorterScene({ simulation }: { simulation: SimulationState }) { const current = simulation.currentItem; const category = current?.classification.category; @@ -49,114 +71,144 @@ export default function SorterScene({ simulation }: { simulation: SimulationStat const position = itemPosition(simulation); const detecting = simulation.machineState === 'DETECTING'; const routeVisible = simulation.machineState.startsWith('ROUTE_TO_'); + const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP'; + const itemWidth = current ? Math.max(24, Math.min(74, current.item.dimensionsMm.width / 6)) : 56; + const itemHeight = current ? Math.max(18, Math.min(58, current.item.dimensionsMm.depth / 5.5)) : 48; return (
-

Work zone 6000 x 10000 mm

-

Sorting line digital twin

+

Work zone 6000 x 10000 mm / conveyor 500 mm

+

Engineering layout, sensors and routing commands

-
{simulation.machineState}
+
{simulation.machineState}
- + - - + + - - - - - - - - - + + + + + {(['B', 'C', 'D'] as Category[]).map((route) => ( + + + + ))} - - + + + Scaled plan: 6000 mm x 10000 mm work cell + + + + - - A feed + + A feed zone - - B main sorter + + B main sorter - - D shape / repack + + D roll-cage 1200 x 800 x 800 mm - - C oversize cage + + C roll-cage 1200 x 800 x 800 mm - - - {Array.from({ length: 16 }).map((_, index) => ( - + + + {Array.from({ length: 18 }).map((_, index) => ( + ))} - - - Camera + + + Camera / bbox - - - Laser + + + Laser height - - - Ultrasound + + + Ultrasonic gate - - Stop-gate {simulation.gate.open ? 'open' : 'closed'} + + Stop-gate {simulation.gate.open ? 'open' : 'closed'} - - Pusher C + + Pusher C command - - Pusher D + + Pusher D command - {routeVisible && category === 'B' ? : null} - {routeVisible && category === 'C' ? : null} - {routeVisible && category === 'D' ? : null} + + + + {routeVisible && category ? ( + + + {simulation.machineState} + + ) : null} {current ? ( - {current.item.id} + {current.item.id} {detecting ? ( - + + + bbox {current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} mm + ) : null} ) : null} - Conveyor width 500 mm / height 700 mm - SVG scene is scaled for operator overview + {stopped ? ( + + + {simulation.machineState} + Conveyor stopped, reset required + + ) : null} + + + + + + + +
); diff --git a/src/components/TimelinePanel.tsx b/src/components/TimelinePanel.tsx new file mode 100644 index 0000000..3cd40f0 --- /dev/null +++ b/src/components/TimelinePanel.tsx @@ -0,0 +1,39 @@ +import { buildCycleTimeline } from '../domain/simulation'; +import type { SimulationState } from '../domain/types'; + +function formatMs(ms?: number): string { + return ms === undefined ? '-' : `t+${(ms / 1000).toFixed(1)}s`; +} + +export default function TimelinePanel({ simulation }: { simulation: SimulationState }) { + const timeline = buildCycleTimeline(simulation); + const current = simulation.currentItem; + const totalCycleTime = current ? simulation.simTimeMs - current.startedAtMs : 0; + + return ( +
+
+

Cycle timeline

+

{current ? current.item.id : 'No active cycle'}

+
+
+
total cycle{totalCycleTime} ms
+
cv latency{simulation.metrics.cvLatencyMs} ms
+
actuator latency{simulation.metrics.actuatorLatencyMs} ms
+
conveyor{simulation.metrics.conveyorSpeedMps.toFixed(2)} m/s
+
queue delay{simulation.metrics.queueDelayMs} ms
+
+
+ {timeline.map((entry) => ( +
+ + {entry.state} + {formatMs(entry.startedAtMs)} + {entry.durationMs} ms + {entry.status} +
+ ))} +
+
+ ); +} diff --git a/src/data/scenarios.test.ts b/src/data/scenarios.test.ts new file mode 100644 index 0000000..d60f639 --- /dev/null +++ b/src/data/scenarios.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { SCENARIOS } from './scenarios'; +import type { ScenarioId } from '../domain/types'; + +const expectedIds: ScenarioId[] = [ + 'normal_flow', + 'oversized_item', + 'round_object', + 'boundary_dimensions', + 'close_items', + 'low_confidence', + 'jam', + 'emergency_stop', +]; + +describe('scenarios', () => { + it('contains every expected scenario id', () => { + const ids = SCENARIOS.map((scenario) => scenario.id); + + expect(ids).toEqual(expectedIds); + }); + + it('contains items and demo metadata for every scenario', () => { + for (const scenario of SCENARIOS) { + expect(scenario.items.length).toBeGreaterThan(0); + expect(scenario.goal.length).toBeGreaterThan(0); + expect(scenario.expectedCategorySummary.length).toBeGreaterThan(0); + expect(scenario.demonstrates.length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/data/scenarios.ts b/src/data/scenarios.ts index 480e3b5..7e77e1d 100644 --- a/src/data/scenarios.ts +++ b/src/data/scenarios.ts @@ -6,36 +6,54 @@ export const SCENARIOS: Scenario[] = [ id: 'normal_flow', name: 'Normal flow', description: 'Обычный поток из нескольких товаров B/C/D с полным циклом сортировки.', + goal: 'Показать базовую последовательность sensor -> gate -> classifier -> actuator.', + expectedCategorySummary: 'Mixed: B, C, D', + demonstrates: 'Стабильный поток, корректную маршрутизацию и возврат исполнительных механизмов домой.', items: ['SKU-001', 'SKU-006', 'SKU-004', 'SKU-003', 'SKU-007'].map(getItem), }, { id: 'oversized_item', name: 'Oversized item', description: 'Товар выходит за max dimensions и должен попасть в roll-cage C.', + goal: 'Доказать приоритет проверки габаритов.', + expectedCategorySummary: 'C for every item', + demonstrates: 'Негабаритный товар фиксируется stop-gate и уводится pusher C в roll-cage C.', items: ['SKU-004', 'SKU-005'].map(getItem), }, { id: 'round_object', name: 'Round object', description: 'Габариты проходят, но roundness >= 0.8, маршрут в D.', + goal: 'Показать проверку круглого сечения после габаритов.', + expectedCategorySummary: 'D for every item', + demonstrates: 'Rule-based shape issue routing без реального ML на MVP-этапе.', items: ['SKU-006', 'SKU-007', 'SKU-008'].map(getItem), }, { id: 'boundary_dimensions', name: 'Boundary dimensions', description: 'Товары около min/max границ показывают устойчивость правил.', + goal: 'Проверить строгие границы min/max.', + expectedCategorySummary: 'Boundary box -> B, Pen -> C', + demonstrates: '450 x 320 x 320 проходит, width 9 мм не проходит min width.', items: ['SKU-010', 'SKU-009', 'SKU-002'].map(getItem), }, { id: 'close_items', name: 'Close items', description: 'Два товара близко друг к другу: warning queue/spacing и последовательная обработка.', + goal: 'Показать устойчивость очереди без усложнения физики.', + expectedCategorySummary: 'Sequential B, B, D', + demonstrates: 'Spacing warning, queue length и обработку товаров по одному циклу.', items: ['SKU-001', 'SKU-002', 'SKU-006'].map(getItem), }, { id: 'low_confidence', name: 'Low confidence', description: 'CV confidence ниже 0.65, система предупреждает и принимает rule-based решение.', + goal: 'Показать fallback при низкой уверенности pseudo-CV.', + expectedCategorySummary: 'Rule-based B and D despite low CV confidence', + demonstrates: 'Низкая confidence не блокирует решение, так как финальная логика основана на правилах.', items: [ { ...getItem('SKU-003'), id: 'SKU-003-LC', confidence: 0.58 }, { ...getItem('SKU-006'), id: 'SKU-006-LC', confidence: 0.61 }, @@ -45,12 +63,18 @@ export const SCENARIOS: Scenario[] = [ id: 'jam', name: 'Jam at gate', description: 'Застревание у stop-gate переводит систему в FAULT и останавливает конвейер.', + goal: 'Показать fail-safe состояние при застревании.', + expectedCategorySummary: 'FAULT before route completion', + demonstrates: 'Conveyor speed падает к 0, state фиксируется в FAULT, требуется Reset.', items: ['SKU-004'].map(getItem), }, { id: 'emergency_stop', name: 'Emergency stop', description: 'Аварийная остановка переводит систему в EMERGENCY_STOP, движение остановлено.', + goal: 'Показать ручную/аварийную остановку всей линии.', + expectedCategorySummary: 'EMERGENCY_STOP before route completion', + demonstrates: 'Все движения останавливаются, PID target становится 0, требуется Reset.', items: ['SKU-001', 'SKU-006'].map(getItem), }, ]; diff --git a/src/domain/classifier.test.ts b/src/domain/classifier.test.ts new file mode 100644 index 0000000..2d59be4 --- /dev/null +++ b/src/domain/classifier.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { classifyItem } from './classifier'; +import { getItem } from '../data/items'; +import type { Item } from './types'; + +describe('classifyItem', () => { + it('routes normal box to B', () => { + expect(classifyItem(getItem('SKU-001')).category).toBe('B'); + }); + + it('routes oversized box to C', () => { + expect(classifyItem(getItem('SKU-004')).category).toBe('C'); + }); + + it('routes round plate to D', () => { + expect(classifyItem(getItem('SKU-006')).category).toBe('D'); + }); + + it('routes pen with width below 10 mm to C', () => { + expect(classifyItem(getItem('SKU-009')).category).toBe('C'); + }); + + it('keeps C priority when item is oversized and round', () => { + const item: Item = { + ...getItem('SKU-005'), + id: 'TEST-OVERSIZED-ROUND', + roundness: 0.95, + }; + + const result = classifyItem(item); + + expect(result.category).toBe('C'); + expect(result.dimensionsPass).toBe(false); + expect(result.roundnessPass).toBe(false); + }); + + it('accepts boundary item 450x320x320 as B', () => { + expect(classifyItem(getItem('SKU-010')).category).toBe('B'); + }); +}); diff --git a/src/domain/metrics.ts b/src/domain/metrics.ts index 2b17afc..3500fda 100644 --- a/src/domain/metrics.ts +++ b/src/domain/metrics.ts @@ -10,6 +10,7 @@ export function createMetrics(): Metrics { cvLatencyMs: 0, actuatorLatencyMs: 0, queueLength: 0, + queueDelayMs: 0, conveyorSpeedMps: 0, pidTargetSpeedMps: 0.42, pidActualSpeedMps: 0, diff --git a/src/domain/pid.test.ts b/src/domain/pid.test.ts new file mode 100644 index 0000000..30924ea --- /dev/null +++ b/src/domain/pid.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { createPidState, updatePid } from './pid'; + +describe('PID conveyor speed imitation', () => { + it('moves actual speed toward target in normal mode', () => { + let pid = createPidState(0.42); + + for (let index = 0; index < 20; index += 1) { + pid = updatePid(pid, 0.42, 250); + } + + expect(pid.actualSpeedMps).toBeGreaterThan(0.3); + expect(Math.abs(pid.pidError)).toBeLessThan(0.15); + }); + + it('decays actual speed toward zero when target is stopped', () => { + let pid = createPidState(0.42); + + for (let index = 0; index < 20; index += 1) { + pid = updatePid(pid, 0.42, 250); + } + const runningSpeed = pid.actualSpeedMps; + + for (let index = 0; index < 30; index += 1) { + pid = updatePid(pid, 0, 250); + } + + expect(pid.actualSpeedMps).toBeLessThan(runningSpeed); + expect(pid.actualSpeedMps).toBeLessThan(0.05); + }); +}); diff --git a/src/domain/pid.ts b/src/domain/pid.ts index 35dd1bc..1ab83fc 100644 --- a/src/domain/pid.ts +++ b/src/domain/pid.ts @@ -1,11 +1,14 @@ import type { PidState } from './types'; +const PID_HISTORY_LIMIT = 40; + export function createPidState(targetSpeedMps = 0.42): PidState { return { targetSpeedMps, actualSpeedMps: 0, pidError: targetSpeedMps, correction: 0, + speedHistoryMps: [0], }; } @@ -14,11 +17,14 @@ export function updatePid(state: PidState, targetSpeedMps: number, deltaMs: numb const pidError = targetSpeedMps - state.actualSpeedMps; const correction = pidError * 0.42; const nextActual = Math.max(0, state.actualSpeedMps + correction * dt); + const actualSpeedMps = Number(nextActual.toFixed(3)); + const speedHistoryMps = [...state.speedHistoryMps, actualSpeedMps].slice(-PID_HISTORY_LIMIT); return { targetSpeedMps, - actualSpeedMps: Number(nextActual.toFixed(3)), + actualSpeedMps, pidError: Number(pidError.toFixed(3)), correction: Number(correction.toFixed(3)), + speedHistoryMps, }; } diff --git a/src/domain/simulation.ts b/src/domain/simulation.ts index 4707db9..e25dfef 100644 --- a/src/domain/simulation.ts +++ b/src/domain/simulation.ts @@ -3,6 +3,7 @@ import { createMetrics, recordCycle } from './metrics'; import { createPidState, updatePid } from './pid'; import type { Category, + CycleTimelineEntry, EventLogEntry, MachineState, Scenario, @@ -10,7 +11,7 @@ import type { SystemStatus, } from './types'; -const STATE_DURATIONS_MS: Partial> = { +export const STATE_DURATIONS_MS: Record, number> = { MOVING_TO_CAMERA: 1200, DETECTING: 900, MOVING_TO_GATE: 1300, @@ -22,6 +23,18 @@ const STATE_DURATIONS_MS: Partial> = { RETURN_HOME: 700, }; +export const CYCLE_STATE_ORDER: MachineState[] = [ + 'MOVING_TO_CAMERA', + 'DETECTING', + 'MOVING_TO_GATE', + 'WAITING_AT_GATE', + 'CLASSIFYING', + 'ROUTE_TO_B', + 'ROUTE_TO_C', + 'ROUTE_TO_D', + 'RETURN_HOME', +]; + const ROUTE_STATE_BY_CATEGORY: Record = { B: 'ROUTE_TO_B', C: 'ROUTE_TO_C', @@ -30,7 +43,9 @@ const ROUTE_STATE_BY_CATEGORY: Record = { let eventCounter = 0; -function event(timestampMs: number, entry: Omit): EventLogEntry { +type EventInput = Omit; + +function event(timestampMs: number, entry: EventInput): EventLogEntry { eventCounter += 1; return { id: `evt-${eventCounter}`, @@ -39,10 +54,10 @@ function event(timestampMs: number, entry: Omit): SimulationState { +function appendEvent(state: SimulationState, entry: EventInput): SimulationState { return { ...state, - events: [event(state.simTimeMs, entry), ...state.events].slice(0, 80), + events: [event(state.simTimeMs, { state: state.machineState, ...entry }), ...state.events].slice(0, 120), }; } @@ -63,8 +78,10 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati next = appendEvent(next, { itemId: item.id, type: 'sensor', - message: `Camera captured bbox, confidence ${Math.round(item.confidence * 100)}%`, + message: `Camera bbox ${item.dimensionsMm.width}x${item.dimensionsMm.depth} mm, confidence ${Math.round(item.confidence * 100)}%`, category: state.currentItem?.classification.category, + command: 'CAMERA_CAPTURE', + state: nextState, status: item.confidence < 0.65 ? 'warning' : 'info', }); } @@ -74,6 +91,8 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati itemId: item.id, type: 'actuator', message: 'Stop-gate closed, item fixed before classification', + command: 'GATE_CLOSE', + state: nextState, status: 'info', }); } @@ -84,6 +103,8 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati type: 'classification', message: `${state.currentItem.classification.label}: ${state.currentItem.classification.reason}`, category: state.currentItem.classification.category, + command: 'CLASSIFY_RULE_BASED', + state: nextState, status: state.currentItem.classification.warnings.length > 0 ? 'warning' : 'success', }); } @@ -94,6 +115,8 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati type: 'routing', message: `Route selected: ${state.currentItem.classification.category}`, category: state.currentItem.classification.category, + command: nextState, + state: nextState, status: 'success', }); } @@ -104,7 +127,7 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati function startNextItem(state: SimulationState): SimulationState { const item = state.scenario.items[state.itemIndex]; if (!item) { - return { ...state, machineState: 'IDLE', running: false, systemStatus: 'PAUSED' }; + return { ...state, machineState: 'IDLE', running: false, systemStatus: 'PAUSED', currentItem: undefined }; } const classification = classifyItem(item); @@ -118,6 +141,7 @@ function startNextItem(state: SimulationState): SimulationState { }, machineState: 'MOVING_TO_CAMERA', elapsedInStateMs: 0, + activeRoute: undefined, itemIndex: state.itemIndex + 1, }; @@ -125,6 +149,8 @@ function startNextItem(state: SimulationState): SimulationState { itemId: item.id, type: 'system', message: `Item entered zone A: ${item.name}`, + command: 'FEED_ITEM', + state: 'MOVING_TO_CAMERA', status: 'info', }); @@ -133,6 +159,8 @@ function startNextItem(state: SimulationState): SimulationState { itemId: item.id, type: 'warning', message: 'Queue/spacing warning: next item detected too close, processing sequentially', + command: 'QUEUE_HOLD_NEXT_ITEM', + state: 'MOVING_TO_CAMERA', status: 'warning', }); } @@ -143,6 +171,8 @@ function startNextItem(state: SimulationState): SimulationState { type: 'warning', message: warning, category: classification.category, + command: 'RULE_BASED_FALLBACK', + state: 'MOVING_TO_CAMERA', status: 'warning', }); } @@ -162,6 +192,10 @@ function completeCurrentItem(state: SimulationState): SimulationState { let next = appendEvent( { ...state, + currentItem: { + ...state.currentItem, + cycleTimeMs, + }, metrics, }, { @@ -169,13 +203,13 @@ function completeCurrentItem(state: SimulationState): SimulationState { type: 'routing', message: `Cycle completed in ${(cycleTimeMs / 1000).toFixed(1)}s`, category: state.currentItem.classification.category, + command: 'CYCLE_COMPLETE', status: success ? 'success' : 'error', }, ); next = { ...next, - currentItem: undefined, activeRoute: undefined, }; @@ -315,6 +349,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte itemId: next.currentItem?.item.id, type: 'fault', message: 'Emergency stop pressed, all movement stopped', + command: 'EMERGENCY_STOP', status: 'error', }); next = { ...next, machineState: 'EMERGENCY_STOP', running: false, systemStatus: 'EMERGENCY_STOP' }; @@ -327,6 +362,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte type: 'fault', message: 'Jam detected at stop-gate, conveyor stopped', category: next.currentItem?.classification.category, + command: 'CONVEYOR_STOP_FAULT', status: 'error', }); next = { ...next, machineState: 'FAULT', running: false, systemStatus: 'FAULT' }; @@ -350,7 +386,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte next = startNextItem(next); } - const duration = STATE_DURATIONS_MS[next.machineState]; + const duration = next.machineState in STATE_DURATIONS_MS ? STATE_DURATIONS_MS[next.machineState as keyof typeof STATE_DURATIONS_MS] : undefined; if (duration && next.elapsedInStateMs >= duration) { switch (next.machineState) { case 'MOVING_TO_CAMERA': @@ -376,7 +412,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte next = completeCurrentItem(next); break; case 'RETURN_HOME': - next = next.itemIndex < next.scenario.items.length ? startNextItem(next) : { ...next, machineState: 'IDLE', running: false, systemStatus: 'PAUSED', elapsedInStateMs: 0 }; + next = next.itemIndex < next.scenario.items.length ? startNextItem(next) : { ...next, currentItem: undefined, machineState: 'IDLE', running: false, systemStatus: 'PAUSED', elapsedInStateMs: 0 }; break; default: break; @@ -388,6 +424,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte const queueLength = Math.max(next.scenario.items.length - next.itemIndex, 0) + (next.currentItem ? 1 : 0); const cvLatencyMs = next.sensors.camera.cvLatencyMs || next.metrics.cvLatencyMs; const actuatorLatencyMs = next.actuators.pusherC !== 'idle' || next.actuators.pusherD !== 'idle' ? 115 : next.machineState === 'ROUTE_TO_B' ? 55 : 0; + const queueDelayMs = next.scenario.id === 'close_items' && queueLength > 1 ? 450 : 0; next = { ...next, @@ -398,6 +435,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte cvLatencyMs, actuatorLatencyMs, queueLength, + queueDelayMs, conveyorSpeedMps: pid.actualSpeedMps, pidTargetSpeedMps: pid.targetSpeedMps, pidActualSpeedMps: pid.actualSpeedMps, @@ -406,3 +444,57 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte return deriveSensorsAndActuators(next); } + +export function stepSimulationToNextState(state: SimulationState): SimulationState { + if (state.machineState === 'FAULT' || state.machineState === 'EMERGENCY_STOP') { + return state; + } + + const runningState = setRunning({ ...state, running: true, systemStatus: 'RUNNING' }, true); + let deltaMs = 1; + + if (runningState.scenario.id === 'emergency_stop') { + deltaMs = Math.max(1, 1800 - runningState.simTimeMs); + } else if (runningState.scenario.id === 'jam' && runningState.machineState === 'WAITING_AT_GATE') { + deltaMs = Math.max(1, 700 - runningState.elapsedInStateMs); + } else if (runningState.machineState in STATE_DURATIONS_MS) { + const duration = STATE_DURATIONS_MS[runningState.machineState as keyof typeof STATE_DURATIONS_MS]; + deltaMs = Math.max(1, duration - runningState.elapsedInStateMs); + } + + const stepped = stepSimulation(runningState, deltaMs, true); + return setRunning(stepped, false); +} + +export function buildCycleTimeline(state: SimulationState): CycleTimelineEntry[] { + const current = state.currentItem; + const activeRoute = current ? ROUTE_STATE_BY_CATEGORY[current.classification.category] : undefined; + let cursor = current?.startedAtMs ?? state.simTimeMs; + + return CYCLE_STATE_ORDER.map((machineState) => { + const isRouteState = machineState.startsWith('ROUTE_TO_'); + const skipped = isRouteState && machineState !== activeRoute; + const durationMs = STATE_DURATIONS_MS[machineState as keyof typeof STATE_DURATIONS_MS] ?? 0; + const startedAtMs = current && !skipped ? cursor : undefined; + let status: CycleTimelineEntry['status'] = current ? 'pending' : 'pending'; + + if (skipped) { + status = 'skipped'; + } else if (current && machineState === state.machineState) { + status = 'active'; + } else if (current && startedAtMs !== undefined && state.simTimeMs >= startedAtMs + durationMs) { + status = 'done'; + } + + if (!skipped) { + cursor += durationMs; + } + + return { + state: machineState, + startedAtMs, + durationMs, + status, + }; + }); +} diff --git a/src/domain/types.ts b/src/domain/types.ts index 87ff7d5..9dadfd0 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -30,6 +30,7 @@ export type SensorKind = 'camera' | 'laser' | 'ultrasound'; export type EventType = 'system' | 'sensor' | 'classification' | 'actuator' | 'routing' | 'warning' | 'fault'; export type EventStatus = 'info' | 'success' | 'warning' | 'error'; export type PusherState = 'idle' | 'extended' | 'retracting'; +export type TimelineStatus = 'done' | 'active' | 'pending' | 'skipped'; export interface DimensionsMm { width: number; @@ -65,6 +66,9 @@ export interface Scenario { id: ScenarioId; name: string; description: string; + goal: string; + expectedCategorySummary: string; + demonstrates: string; items: Item[]; initialStatus?: SystemStatus; } @@ -76,6 +80,8 @@ export interface EventLogEntry { type: EventType; message: string; category?: Category; + command?: string; + state?: MachineState; status: EventStatus; } @@ -88,6 +94,7 @@ export interface Metrics { cvLatencyMs: number; actuatorLatencyMs: number; queueLength: number; + queueDelayMs: number; conveyorSpeedMps: number; pidTargetSpeedMps: number; pidActualSpeedMps: number; @@ -141,6 +148,14 @@ export interface PidState { actualSpeedMps: number; pidError: number; correction: number; + speedHistoryMps: number[]; +} + +export interface CycleTimelineEntry { + state: MachineState; + startedAtMs?: number; + durationMs: number; + status: TimelineStatus; } export interface SimulatedItem { diff --git a/src/styles.css b/src/styles.css index a2c230c..41a0fd2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -518,3 +518,337 @@ h2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + + +button:disabled { + cursor: not-allowed; + opacity: 0.45; + transform: none; +} + +.sorter-svg { + height: 660px; +} + +.panel-scene { + min-height: 746px; +} + +.left-label { + text-anchor: start; +} + +.conveyor { + fill: #101f30; + stroke: #4b647d; + stroke-width: 2; +} + +.conveyor.stopped { + fill: #2a1219; + stroke: var(--red); + filter: drop-shadow(0 0 12px rgba(251, 61, 78, 0.35)); +} + +.belt-center { + stroke: #7b8fa5; + stroke-width: 2; + stroke-dasharray: 18 12; +} + +.roller-line { + stroke: #243d55; + stroke-width: 3; +} + +.dimension-line line { + stroke: #6c849d; + stroke-width: 1.4; + stroke-dasharray: 5 5; +} + +.dimension-line circle { + fill: #6c849d; +} + +.dimension-line text, +.legend-item text, +.route-command text { + fill: #d9e8f7; + font-size: 12px; + font-weight: 800; + text-anchor: middle; +} + +.scene-legend text { + text-anchor: start; + fill: #b8c8da; + font-size: 11px; +} + +.route-guide { + fill: none; + stroke-width: 3; + stroke-dasharray: 10 9; + opacity: 0.34; +} + +.route-b { stroke: var(--green); } +.route-c { stroke: var(--orange); } +.route-d { stroke: var(--purple); } + +.route-command text { + fill: #05101b; + font-size: 13px; +} + +.bbox rect { + fill: none; + stroke: var(--cyan); + stroke-width: 2; + stroke-dasharray: 6 5; + filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.7)); +} + +.bbox text { + fill: var(--cyan); + font-size: 12px; + text-anchor: middle; + font-weight: 800; +} + +.fault-chip { + border-color: var(--red); + color: #fecdd3; +} + +.fault-overlay rect { + fill: rgba(54, 12, 22, 0.88); + stroke: var(--red); + stroke-width: 2; +} + +.fault-overlay text { + fill: #fecdd3; + font-size: 16px; + font-weight: 900; + text-anchor: middle; +} + +.pseudo-cv-block { + margin-top: 12px; + padding: 10px; + border: 1px solid #24445f; + border-radius: 14px; + background: #071423; +} + +.pseudo-cv-block h3 { + margin: 0 0 8px; + color: var(--cyan); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.compact-kv { + grid-template-columns: 116px 1fr; +} + +.classification-panel .panel-heading { + margin-bottom: 10px; +} + +.rule-stack { + display: grid; + gap: 9px; +} + +.rule-card { + padding: 10px; + border: 1px solid #243e57; + border-radius: 13px; + background: #081422; +} + +.rule-card div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.rule-card strong { + font-size: 13px; +} + +.rule-card p { + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.verdict, +.decision-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 3px 8px; + border: 1px solid #334d66; + border-radius: 999px; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.06em; +} + +.verdict.pass { color: var(--green); border-color: var(--green); } +.verdict.fail { color: var(--orange); border-color: var(--orange); } +.verdict.pending, +.verdict.skipped { color: var(--muted); } +.warning-note { + margin-top: 6px; + color: #facc15 !important; +} + +.bottom-row { + grid-template-columns: 1fr 1.15fr 0.82fr 1.35fr 1fr; + align-items: start; +} + +.timeline-panel { + padding: 14px; +} + +.timeline-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 10px; +} + +.timeline-summary div { + padding: 8px; + border: 1px solid #243e57; + border-radius: 10px; + background: #081422; +} + +.timeline-summary span, +.timeline-summary strong { + display: block; + font-size: 11px; +} + +.timeline-summary span { + color: var(--muted); +} + +.timeline-list { + display: grid; + gap: 6px; + max-height: 260px; + overflow: auto; +} + +.timeline-row { + display: grid; + grid-template-columns: 14px minmax(130px, 1fr) 60px 62px 58px; + align-items: center; + gap: 7px; + padding: 7px 8px; + border: 1px solid #203950; + border-radius: 10px; + color: #b8c8da; + font-size: 11px; +} + +.timeline-row strong { + font-size: 11px; +} + +.timeline-row em { + color: var(--muted); + font-style: normal; + font-weight: 800; +} + +.timeline-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #31485f; +} + +.timeline-row.done .timeline-dot { background: var(--green); } +.timeline-row.active { + border-color: var(--cyan); + background: rgba(56, 189, 248, 0.09); +} +.timeline-row.active .timeline-dot { background: var(--cyan); box-shadow: 0 0 12px var(--cyan); } +.timeline-row.skipped { opacity: 0.48; } +.timeline-row.skipped .timeline-dot { background: #475569; } + +.pid-sparkline { + width: 100%; + height: 72px; + margin: 4px 0 10px; + border: 1px solid #243e57; + border-radius: 12px; + background: #071423; +} + +.pid-sparkline path { + stroke: #223c55; + stroke-width: 1; +} + +.pid-sparkline polyline { + fill: none; + stroke: var(--cyan); + stroke-width: 3; + stroke-linejoin: round; + stroke-linecap: round; +} + +.log-filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.log-filters button, +.small-button { + min-height: 28px; + padding: 4px 8px; + font-size: 11px; +} + +.log-filters button.active { + border-color: var(--cyan); + background: rgba(56, 189, 248, 0.12); +} + +.event-row { + grid-template-columns: 62px 84px 116px 88px 128px minmax(260px, 1fr) 68px; +} + +.scenario-buttons button em { + display: block; + margin-top: 5px; + color: #b6c6d8; + font-size: 11px; + font-style: normal; + font-weight: 650; +} + +.metric-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 1500px) { + .bottom-row { + grid-template-columns: 1fr 1fr; + } +}