From 2c87bed5be2ef64e10b1ddb514ed1a8d51db180e Mon Sep 17 00:00:00 2001 From: CharlieNode Date: Wed, 15 Apr 2026 23:47:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(mes):=20Week=201=20=E2=80=94=20PostgreSQL?= =?UTF-8?q?=20mes=5Fcore=20schema=20+=20Alembic=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces services/mes/ — the MES Core microservice layer for FactoryLM. Implements Walker Reynolds' 8-Week MES Bootcamp schema (Session 1) translated to our stack. Changes: - services/mes/: FastAPI skeleton + SQLAlchemy 2.x + Alembic setup - 7 tables: lines, products, work_orders, schedules, downtime_reasons, machine_states, oee_snapshots - 5 PostgreSQL enum types + 6 performance indexes - Seed: 14 downtime reason codes, 2 production lines (ISA-95 paths) - Pydantic UDTs: LineDataType, CountDispatch, OEEDataType - infra/scada/mes.Dockerfile + docker-compose.yml (port 5434) - 11/11 acceptance tests passing Closes: Mikecranesync/MIRA#319 PRD: docs/PRD-MES-CORE.md Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- PLAN.md | 81 ++++ docker-compose.yml | 41 ++ docs/PRD-MES-CORE.md | 440 ++++++++++++++++++ infra/scada/mes.Dockerfile | 17 + services/mes/alembic.ini | 41 ++ services/mes/alembic/env.py | 56 +++ services/mes/alembic/script.py.mako | 26 ++ .../versions/0001_initial_mes_schema.py | 212 +++++++++ services/mes/backend/__init__.py | 0 services/mes/backend/config.py | 26 ++ services/mes/backend/database.py | 28 ++ services/mes/backend/main.py | 46 ++ services/mes/backend/models/__init__.py | 0 services/mes/backend/models/db_models.py | 238 ++++++++++ services/mes/backend/models/mes_models.py | 129 +++++ services/mes/backend/routes/__init__.py | 0 services/mes/backend/routes/health.py | 28 ++ services/mes/requirements.txt | 7 + services/mes/tests/__init__.py | 0 services/mes/tests/conftest.py | 35 ++ services/mes/tests/test_schema.py | 136 ++++++ 21 files changed, 1587 insertions(+) create mode 100644 PLAN.md create mode 100644 docs/PRD-MES-CORE.md create mode 100644 infra/scada/mes.Dockerfile create mode 100644 services/mes/alembic.ini create mode 100644 services/mes/alembic/env.py create mode 100644 services/mes/alembic/script.py.mako create mode 100644 services/mes/alembic/versions/0001_initial_mes_schema.py create mode 100644 services/mes/backend/__init__.py create mode 100644 services/mes/backend/config.py create mode 100644 services/mes/backend/database.py create mode 100644 services/mes/backend/main.py create mode 100644 services/mes/backend/models/__init__.py create mode 100644 services/mes/backend/models/db_models.py create mode 100644 services/mes/backend/models/mes_models.py create mode 100644 services/mes/backend/routes/__init__.py create mode 100644 services/mes/backend/routes/health.py create mode 100644 services/mes/requirements.txt create mode 100644 services/mes/tests/__init__.py create mode 100644 services/mes/tests/conftest.py create mode 100644 services/mes/tests/test_schema.py diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..eaa5d67 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,81 @@ +# PLAN: MES Core — Week 1 (DB Schema + Alembic Migrations) + +**Branch:** `feat/mes-week1-db-schema` +**Issue:** Mikecranesync/MIRA#319 +**PRD:** `docs/PRD-MES-CORE.md` +**Date:** 2026-04-15 + +--- + +## Objective + +Stand up the `mes_core` PostgreSQL schema — the foundational data layer for Work Orders, OEE, Machine States, and Downtime Tracking. All subsequent MES weeks depend on this being clean and stable. + +## Affected Files + +**New:** +- `services/mes/requirements.txt` +- `services/mes/backend/__init__.py` +- `services/mes/backend/config.py` +- `services/mes/backend/database.py` +- `services/mes/backend/main.py` +- `services/mes/backend/models/__init__.py` +- `services/mes/backend/models/db_models.py` +- `services/mes/backend/models/mes_models.py` +- `services/mes/backend/routes/__init__.py` +- `services/mes/backend/routes/health.py` +- `services/mes/alembic.ini` +- `services/mes/alembic/env.py` +- `services/mes/alembic/script.py.mako` +- `services/mes/alembic/versions/0001_initial_mes_schema.py` +- `services/mes/tests/__init__.py` +- `services/mes/tests/conftest.py` +- `services/mes/tests/test_schema.py` +- `infra/scada/mes.Dockerfile` + +**Modified:** +- `docker-compose.yml` — add `factorylm-mes-db` (Postgres) and `factorylm-mes` containers + +## Approach + +1. Introduce SQLAlchemy 2.x + Alembic into `services/mes/` — first time in repo +2. Follow existing FastAPI service pattern from `services/plc-modbus/` +3. DB schema: 7 tables (`lines`, `products`, `work_orders`, `schedules`, `downtime_reasons`, `machine_states`, `oee_snapshots`) +4. Seed `downtime_reasons` (14 codes) and `lines` (2 lines) in initial migration +5. Postgres 16 via Docker — `factorylm-mes-db` container on port 5433 (avoids conflict) +6. FastAPI skeleton with `/api/health` only — full routes come in Week 2+ + +## Risks + +- No Alembic precedent in repo — introducing fresh, so migration is the only baseline +- Python 3.9 system — using `Optional[X]` not `X | None` +- Register map divergence (plc-modbus CLAUDE.md vs main CLAUDE.md) — MES uses main CLAUDE.md register map (authoritative) + +## Rollback + +```bash +git checkout main +docker compose down factorylm-mes-db factorylm-mes +``` + +## Verification Steps + +```bash +# Start DB +docker compose up factorylm-mes-db -d + +# Run migration +cd services/mes +DATABASE_URL="postgresql://mes:meslocal@localhost:5433/mes_core" alembic upgrade head + +# Run schema tests +pytest services/mes/tests/test_schema.py -v + +# Health check +docker compose up factorylm-mes -d +curl localhost:8300/api/health +``` + +## Note on Active Focus Window + +The main `CLAUDE.md` declares a Revenue Priority focus on V1 Telegram bot. This MES work has been explicitly requested by Mike (2026-04-15 session) as a parallel track. Proceeding with explicit authorization. diff --git a/docker-compose.yml b/docker-compose.yml index 15abc87..0aba6a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,5 +77,46 @@ services: - modbus-sim restart: unless-stopped + # ── MES PostgreSQL database ─────────────────────────────────────── + mes-db: + image: postgres:16-alpine + container_name: factorylm-mes-db + ports: + - "5434:5432" # 5434 avoids conflict with any local Postgres or SSH tunnels + environment: + POSTGRES_USER: mes + POSTGRES_PASSWORD: meslocal + POSTGRES_DB: mes_core + volumes: + - mes-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mes -d mes_core"] + interval: 5s + timeout: 3s + retries: 5 + restart: unless-stopped + + # ── MES API service ─────────────────────────────────────────────── + mes: + build: + context: . + dockerfile: infra/scada/mes.Dockerfile + container_name: factorylm-mes + ports: + - "8300:8300" + environment: + FACTORYLM_DATABASE_URL: "postgresql://mes:meslocal@mes-db:5432/mes_core" + FACTORYLM_PLC_USE_MOCK: "true" + depends_on: + mes-db: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8300/api/health')"] + interval: 10s + timeout: 5s + retries: 3 + restart: unless-stopped + volumes: node-red-data: + mes-db-data: diff --git a/docs/PRD-MES-CORE.md b/docs/PRD-MES-CORE.md new file mode 100644 index 0000000..2bd1614 --- /dev/null +++ b/docs/PRD-MES-CORE.md @@ -0,0 +1,440 @@ +# PRD: MIRA MES Core — Work Orders, OEE, Downtime, and Scheduling + +**Status:** Draft +**Author:** CHARLIE node +**Reference:** Walker Reynolds / 4.0 Solutions — 8-Week MES Bootcamp (Session 1, Dec 2024) +**Source video:** https://youtu.be/pSX4OBr0iyk +**Target repo:** Mikecranesync/MIRA +**Priority:** P0 (foundational for v1 factory intelligence) + +--- + +## 1. Problem Statement + +MIRA can answer maintenance questions and ingest OEM docs, but it cannot yet tell a technician or manager **what is actually happening on the production floor right now.** There is no: + +- Live machine state (running / down / changeover / idle) +- OEE score derived from real PLC data +- Work order lifecycle owned by MIRA +- Scheduled production run tied to a physical line + +Walker Reynolds defines MES as the **digital bridge between the shop floor and the boardroom**. The board asks: "Do we need to build a new facility?" The answer lives in OEE and capacity data. Without this layer, MIRA is a knowledge tool, not an execution system. This PRD closes that gap. + +--- + +## 2. Reference Architecture (Walker Reynolds / 4.0 Solutions) + +The bootcamp teaches one complete MES stack. We adopt the same logical design and re-implement it on the FactoryLM infrastructure. + +### 2.1 Walker's Stack → Our Stack Mapping + +| Walker's Component | Walker's Tool | MIRA Equivalent | +|---|---|---| +| MES Platform | Ignition (Inductive Automation) | mira-pipeline (FastAPI) + Open WebUI | +| Database | MySQL | PostgreSQL (new `mes_core` schema) or extend mira.db | +| Scripting | Python within Ignition | Python microservices in `services/mes/` | +| MQTT Broker | EMQx | Existing SCADA MQTT stack (`:502` → `:8001`) | +| Unified Namespace | Ignition UNS | FactoryLM UNS (ISA-95, being built in #312) | +| ERP Integration | Odoo | Atlas CMMS (work-order integration in PR #279) | +| UI | Ignition Vision / Perspective | Open WebUI + mira-web dashboard | +| Machine state source | OPC-UA / Kepware | Modbus PLC driver (`services/plc-modbus/`) | + +### 2.2 ISA-95 Namespace Structure (Walker's model, our labels) + +``` +Site (Lake Wales) + └── Area (Factory Floor) + └── Line (Conveyor-1, Sorting-1 ...) + └── Equipment (VFD-GS10, PLC-Micro820 ...) + +Namespaces per line: + production/ + oee # computed OEE float 0-1 + performance # P component + availability # A component + quality # Q component + good_count # parts passing QC + total_count # all parts produced + run_state # RUNNING | DOWN | CHANGEOVER | IDLE | OFFLINE + + quality/ + reject_count + reject_reason + + maintenance/ + last_fault_code + last_fault_ts + mtbf_hours + mttr_minutes + + kpis/ + teep # TEEP = OEE × utilization + downtime_minutes_today + custom/ # extensible +``` + +--- + +## 3. The Core Four Features (Walker's "Core Four") + +Walker Reynolds calls these non-negotiable. Every MES starts here: + +1. **Work Orders** — create, assign, track, close +2. **Scheduling** — production runs against a schedule +3. **OEE** — Availability × Performance × Quality, live +4. **Downtime Tracking** — machine states + reason codes + +All four must ship together for MIRA to qualify as an MES. + +--- + +## 4. Feature Specifications + +### 4.1 Work Orders + +**What:** A work order represents a discrete production job: product, target quantity, line, start time, end time. + +**Data model:** + +```sql +work_orders ( + id UUID PRIMARY KEY, + order_number TEXT UNIQUE, -- from Atlas CMMS or generated + product_id UUID FK, + line_id UUID FK, + target_qty INTEGER, + good_qty INTEGER DEFAULT 0, + status ENUM('PENDING','ACTIVE','PAUSED','COMPLETE','CANCELLED'), + scheduled_start TIMESTAMPTZ, + actual_start TIMESTAMPTZ, + actual_end TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +) + +products ( + id UUID PRIMARY KEY, + sku TEXT UNIQUE, + name TEXT, + ideal_cycle_sec FLOAT -- used in Performance OEE calculation +) + +lines ( + id UUID PRIMARY KEY, + name TEXT, -- e.g. "Conveyor-1" + isa95_path TEXT, -- e.g. "lakewales/floor/conveyor-1" + plc_host TEXT, -- e.g. "192.168.1.100" + plc_port INTEGER DEFAULT 502 +) +``` + +**Behavior:** +- A technician or MIRA chat can create a work order via REST POST +- Activating a work order broadcasts `ACTIVE` to the UNS topic for that line +- Work order completion auto-calculates final OEE and writes to history +- Atlas CMMS sync: PR #279 already wires work-order creation — this PRD extends it with full lifecycle + +--- + +### 4.2 Production Scheduling + +**What:** Schedules define which work order runs on which line during which shift. + +**Data model:** + +```sql +schedules ( + id UUID PRIMARY KEY, + work_order_id UUID FK, + line_id UUID FK, + shift ENUM('DAY','NIGHT','WEEKEND'), + planned_start TIMESTAMPTZ, + planned_end TIMESTAMPTZ, + planned_qty INTEGER +) +``` + +**Behavior:** +- Scheduler publishes upcoming work orders to UNS `production/schedule` 15 min before start +- Machine operator acknowledges start via MIRA chat or HMI button (Node-RED) +- Schedule adherence = actual_start vs. planned_start, reported in KPI namespace + +--- + +### 4.3 OEE (Overall Equipment Effectiveness) + +**The formula:** +``` +OEE = Availability × Performance × Quality + +Availability = Run Time / Planned Production Time +Performance = (Ideal Cycle Time × Total Count) / Run Time +Quality = Good Count / Total Count + +TEEP = OEE × (Scheduled Time / Max Possible Time) +``` + +**Data model — OEE events:** + +```sql +oee_snapshots ( + id UUID PRIMARY KEY, + line_id UUID FK, + work_order_id UUID FK, + ts TIMESTAMPTZ, + run_time_sec INTEGER, + planned_time_sec INTEGER, + total_count INTEGER, + good_count INTEGER, + ideal_cycle_sec FLOAT, + availability FLOAT, + performance FLOAT, + quality FLOAT, + oee FLOAT, + teep FLOAT +) +``` + +**Calculation service (`services/mes/oee_calculator.py`):** +- Runs every 60s per active line +- Reads PLC counters from Modbus (HR100–HR102 in existing register map) +- Reads downtime events from `machine_state_log` +- Writes snapshot to `oee_snapshots` +- Publishes computed OEE to UNS `production/oee` via MQTT + +**OEE thresholds (Walker's benchmarks):** +- World-class OEE: ≥ 85% +- Typical OEE: 40–60% +- Below 40%: systemic problem, trigger MIRA alert + +--- + +### 4.4 Downtime Tracking & Machine States + +**The "Core Four" anchor.** Walker emphasizes availability (A) is almost always the bottleneck — not performance (P) or quality (Q). + +**Machine state machine:** +``` +IDLE → RUNNING → DOWN → IDLE + ↓ + CHANGEOVER → RUNNING + ↓ + OFFLINE (PLC unreachable) +``` + +**Data model:** + +```sql +machine_states ( + id UUID PRIMARY KEY, + line_id UUID FK, + state ENUM('RUNNING','DOWN','CHANGEOVER','IDLE','OFFLINE'), + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + reason_code TEXT, -- FK to downtime_reasons + entered_by ENUM('PLC','OPERATOR','MIRA_AI') +) + +downtime_reasons ( + code TEXT PRIMARY KEY, + description TEXT, + category ENUM('PLANNED','UNPLANNED','EXTERNAL') + -- examples: MAINT_PM, MAINT_BREAKDOWN, CHANGEOVER_PRODUCT, + -- STARVED_MATERIAL, BLOCKED_DOWNSTREAM, QUALITY_HOLD +) +``` + +**State detection logic:** +- PLC Coil0 (motor_run) = 1 → RUNNING +- PLC Coil2 (fault) = 1 → DOWN +- Motor speed (HR100) = 0 AND no fault → IDLE +- PLC unreachable → OFFLINE +- State transition → write to `machine_states`, publish to UNS + +**Downtime reason capture:** +- AUTO: if fault code present in Modbus HR102, map to reason_code via lookup table +- MANUAL: MIRA chat ("the line is down for a tooling change") → NLP → reason_code +- HMI: Node-RED button panel at `:1880/ui` — operator selects reason from list + +--- + +## 5. UDT Definitions (Walker's Week 4 Concept → our Pydantic models) + +Walker builds UDTs in Ignition. We use Pydantic dataclasses as the equivalent. + +```python +# services/mes/models.py + +class LineDataType(BaseModel): + line_id: str + isa95_path: str + run_state: Literal["RUNNING","DOWN","CHANGEOVER","IDLE","OFFLINE"] + oee: float + availability: float + performance: float + quality: float + good_count: int + total_count: int + active_work_order_id: Optional[str] + ts: datetime + +class CountDispatch(BaseModel): + line_id: str + count_type: Literal["GOOD","REJECT","TOTAL"] + delta: int # increment since last dispatch + ts: datetime + +class OEEDataType(BaseModel): + line_id: str + interval_sec: int + availability: float + performance: float + quality: float + oee: float + teep: float + ts: datetime +``` + +--- + +## 6. API Endpoints (new `services/mes/` microservice) + +``` +POST /api/mes/work-orders # create work order +GET /api/mes/work-orders # list (filter by status, line) +GET /api/mes/work-orders/{id} # detail +PATCH /api/mes/work-orders/{id}/status # PENDING→ACTIVE→COMPLETE +GET /api/mes/lines # list configured lines +GET /api/mes/lines/{id}/state # current machine state +GET /api/mes/lines/{id}/oee # current OEE snapshot +GET /api/mes/lines/{id}/oee/history # time-series OEE (last N hours) +GET /api/mes/lines/{id}/downtime # downtime events (today) +POST /api/mes/lines/{id}/downtime # manual downtime reason entry +GET /api/mes/oee/summary # fleet OEE rollup (all lines) +GET /api/mes/kpis # TEEP, downtime_minutes, schedule_adherence +``` + +Auth: reuse existing PLC API token pattern (Bearer header). + +--- + +## 7. MIRA Chat Integration + +Walker's MES has an HMI for operator input. Ours uses MIRA chat. + +**New intent handlers in `mira-pipeline/gsd_engine.py`:** + +| User says | MIRA does | +|---|---| +| "Start work order W-1042" | POST /api/mes/work-orders/W-1042/status → ACTIVE | +| "Line 1 is down, tooling change" | POST /api/mes/lines/conveyor-1/downtime {reason_code: CHANGEOVER_TOOLING} | +| "What's our OEE today?" | GET /api/mes/oee/summary → format response | +| "How long has line 2 been down?" | GET machine_states WHERE line=2 AND state=DOWN | +| "Complete the current work order" | PATCH /api/mes/work-orders/{active}/status → COMPLETE | + +**MIRA knowledge injection (hooks into #24 Vision→RAG loop):** +- On OEE < 60% for > 30 min → inject context into MIRA chat: "Line 1 OEE has dropped to 47%. Longest downtime reason today: MAINT_BREAKDOWN (42 min)." +- On OFFLINE state > 5 min → trigger Telegram alert + MIRA proactive message + +--- + +## 8. Open WebUI Dashboard Integration + +Walker's Ignition Vision screens → our Open WebUI + mira-web. + +**New views:** +1. **Fleet OEE Board** — all lines, live OEE gauges (A/P/Q breakdown), TEEP +2. **Line Detail** — machine state timeline (Gantt), downtime reasons pie chart, work order progress +3. **Work Order List** — filter by status, line, shift +4. **Downtime Reason Entry** — operator modal (triggered on DOWN state detection) + +These extend issues #302 (persistent memory) and #305 (sub-models) — the MIRA PM sub-model should have full MES context. + +--- + +## 9. Database Migration Plan + +Existing state: `mira.db` (SQLite, issue #274 bind-mount bug). MES data volume and query patterns require PostgreSQL. + +**Migration path:** +1. Fix `mira.db` bind-mount bug (#274) — unblock current SQLite +2. Add `mes_core` schema to existing Postgres (if present) or spin new container in `docker-compose.yml` +3. Alembic migrations for all tables above +4. Do NOT touch existing MIRA tables — additive schema only + +**Docker compose addition:** +```yaml +factorylm-mes-db: + image: postgres:16-alpine + environment: + POSTGRES_DB: mes_core + POSTGRES_USER: mes + POSTGRES_PASSWORD: ${MES_DB_PASSWORD} + volumes: + - mes_db_data:/var/lib/postgresql/data + ports: + - "5433:5432" +``` + +--- + +## 10. Acceptance Criteria + +Each criterion must have deterministic proof (Cluster Law 1): + +| # | Criterion | Proof | +|---|---|---| +| 1 | Work order can be created via REST and via MIRA chat | `curl POST /api/mes/work-orders` returns 201; MIRA chat "start WO-001" creates record in DB | +| 2 | Machine state reads from live PLC Modbus | `curl /api/mes/lines/conveyor-1/state` returns RUNNING when Coil0=1 | +| 3 | OEE calculates correctly | Freeze PLC counters at known values, assert OEE = expected within ±0.01 | +| 4 | Downtime event captured on fault | Set Coil2=1 on mock PLC → `machine_states` row appears within 10s | +| 5 | TEEP reported alongside OEE | `GET /api/mes/kpis` includes `teep` field | +| 6 | MIRA chat can query OEE | "What's our OEE today?" returns fleet summary with numeric values | +| 7 | Open WebUI fleet OEE board renders | Navigate to dashboard, all configured lines show live gauges | +| 8 | Work order close writes final OEE to history | Complete WO via API, `oee_snapshots` has record for that WO | +| 9 | Atlas CMMS work orders sync (bidirectional) | Create WO in CMMS → appears in `/api/mes/work-orders`; create via API → appears in CMMS | +| 10 | All tests pass in CI | `pytest services/mes/ -v` green in GitHub Actions | + +--- + +## 11. Out of Scope (v1) + +- Advanced scheduling optimization (no ML-based scheduling yet) +- Multi-site / multi-area rollup (single factory floor only) +- Quality inspection integration (QC camera, visual defect detection — future) +- ERP bill-of-materials sync (Odoo — future; Atlas CMMS is sufficient for v1) +- Mobile operator app (Open WebUI mobile is sufficient) + +--- + +## 12. Implementation Plan (8-week mirror of Walker's bootcamp) + +| Week | Deliverable | Issues to Create | +|---|---|---| +| 1 | DB schema: PostgreSQL `mes_core`, all tables, Alembic migrations | `feat(mes): database schema v1` | +| 2 | `services/mes/` FastAPI skeleton, line config, Modbus state reader | `feat(mes): machine state service` | +| 3 | OEE calculator service, 60s tick, UNS publish | `feat(mes): oee calculator` | +| 4 | Work order CRUD, scheduling model, Pydantic UDTs | `feat(mes): work order management` | +| 5 | Downtime reason capture (auto + manual + MIRA chat NLP) | `feat(mes): downtime tracking` | +| 6 | Atlas CMMS bidirectional sync | `feat(mes): cmms sync` | +| 7 | Open WebUI fleet dashboard, Line detail view | `feat(mes): owui dashboard` | +| 8 | Full integration test suite, CI green, Acceptance criteria verified | `test(mes): acceptance suite` | + +--- + +## 13. Dependencies / Blockers to Resolve First + +- **#274** — Fix `mira.db` bind-mount (affects DB stability) +- **#275** — Fix `PIPELINE_API_KEY` in Doppler (pipeline must be healthy before MES hooks in) +- **#312** — ISA-95 path on knowledge_entries (already merged — MES uses same ISA-95 paths) +- **#279** — Atlas CMMS work-order creation (foundation for bidirectional sync) +- **CHARLIE Nautobot** — restart 5 exited containers (DCIM is source of truth for line topology) + +--- + +## 14. Success Metric + +A factory manager opens MIRA and asks: **"Should we run overtime tonight or are we on track?"** + +MIRA answers with: current OEE per line, remaining quantity on active work orders, projected completion time based on current performance rate, and whether TEEP indicates hidden capacity. + +That answer does not exist today. This PRD makes it possible. diff --git a/infra/scada/mes.Dockerfile b/infra/scada/mes.Dockerfile new file mode 100644 index 0000000..095daa9 --- /dev/null +++ b/infra/scada/mes.Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies first (layer cache) +COPY services/mes/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy service source +COPY services/mes/ . + +# Expose MES API port +EXPOSE 8300 + +# Run Alembic migrations then start the API +CMD alembic upgrade head && \ + uvicorn backend.main:app --host 0.0.0.0 --port 8300 diff --git a/services/mes/alembic.ini b/services/mes/alembic.ini new file mode 100644 index 0000000..877169a --- /dev/null +++ b/services/mes/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . + +# Use FACTORYLM_DATABASE_URL env var (set via Doppler or docker-compose) +# Override from backend/config.py via env.py +sqlalchemy.url = postgresql://mes:meslocal@localhost:5434/mes_core + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/services/mes/alembic/env.py b/services/mes/alembic/env.py new file mode 100644 index 0000000..d2d2510 --- /dev/null +++ b/services/mes/alembic/env.py @@ -0,0 +1,56 @@ +"""Alembic env.py — reads DATABASE_URL from FACTORYLM_DATABASE_URL env var.""" + +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Add services/mes to path so we can import backend.* +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from backend.database import Base # noqa: E402 — must come after sys.path insert +import backend.models.db_models # noqa: F401 — registers all models with Base + +config = context.config + +# Allow FACTORYLM_DATABASE_URL env var to override alembic.ini +db_url = os.environ.get("FACTORYLM_DATABASE_URL") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/services/mes/alembic/script.py.mako b/services/mes/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/services/mes/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/services/mes/alembic/versions/0001_initial_mes_schema.py b/services/mes/alembic/versions/0001_initial_mes_schema.py new file mode 100644 index 0000000..8c22e85 --- /dev/null +++ b/services/mes/alembic/versions/0001_initial_mes_schema.py @@ -0,0 +1,212 @@ +"""Initial MES Core schema — 7 tables + seed data. + +Revision ID: 0001 +Revises: (none — initial) +Create Date: 2026-04-15 + +Tables created: + lines — production lines (Conveyor-1, Sorting-1 seeded) + products — SKUs with ideal cycle times + work_orders — production job lifecycle + schedules — shift-based scheduling + downtime_reasons — reason code lookup (14 codes seeded) + machine_states — state transition time-series + oee_snapshots — 60s OEE computed snapshots + +Reference: docs/PRD-MES-CORE.md (Walker Reynolds 8-Week MES Bootcamp, Session 1) +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ── Enum types ──────────────────────────────────────────────────────────── + op.execute(""" + CREATE TYPE work_order_status AS ENUM ( + 'PENDING', 'ACTIVE', 'PAUSED', 'COMPLETE', 'CANCELLED' + ) + """) + op.execute(""" + CREATE TYPE machine_state_enum AS ENUM ( + 'RUNNING', 'DOWN', 'CHANGEOVER', 'IDLE', 'OFFLINE' + ) + """) + op.execute(""" + CREATE TYPE downtime_category AS ENUM ( + 'PLANNED', 'UNPLANNED', 'EXTERNAL' + ) + """) + op.execute(""" + CREATE TYPE entered_by_enum AS ENUM ( + 'PLC', 'OPERATOR', 'MIRA_AI' + ) + """) + op.execute(""" + CREATE TYPE shift_enum AS ENUM ( + 'DAY', 'NIGHT', 'WEEKEND' + ) + """) + + # ── lines ───────────────────────────────────────────────────────────────── + op.execute(""" + CREATE TABLE lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT UNIQUE NOT NULL, + isa95_path TEXT NOT NULL, + plc_host TEXT NOT NULL, + plc_port INTEGER NOT NULL DEFAULT 502, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + + # ── products ────────────────────────────────────────────────────────────── + op.execute(""" + CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sku TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + ideal_cycle_sec FLOAT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + + # ── work_orders ─────────────────────────────────────────────────────────── + op.execute(""" + CREATE TABLE work_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_number TEXT UNIQUE NOT NULL, + product_id UUID NOT NULL REFERENCES products(id), + line_id UUID NOT NULL REFERENCES lines(id), + target_qty INTEGER NOT NULL, + good_qty INTEGER NOT NULL DEFAULT 0, + status work_order_status NOT NULL DEFAULT 'PENDING', + scheduled_start TIMESTAMPTZ, + actual_start TIMESTAMPTZ, + actual_end TIMESTAMPTZ, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX idx_work_orders_line_id ON work_orders(line_id)") + op.execute("CREATE INDEX idx_work_orders_status ON work_orders(status)") + + # ── schedules ───────────────────────────────────────────────────────────── + op.execute(""" + CREATE TABLE schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_order_id UUID NOT NULL REFERENCES work_orders(id), + line_id UUID NOT NULL REFERENCES lines(id), + shift shift_enum NOT NULL, + planned_start TIMESTAMPTZ NOT NULL, + planned_end TIMESTAMPTZ NOT NULL, + planned_qty INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + + # ── downtime_reasons ────────────────────────────────────────────────────── + op.execute(""" + CREATE TABLE downtime_reasons ( + code TEXT PRIMARY KEY, + description TEXT NOT NULL, + category downtime_category NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + + # ── machine_states ──────────────────────────────────────────────────────── + op.execute(""" + CREATE TABLE machine_states ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + line_id UUID NOT NULL REFERENCES lines(id), + state machine_state_enum NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + reason_code TEXT REFERENCES downtime_reasons(code), + entered_by entered_by_enum NOT NULL DEFAULT 'PLC', + notes TEXT + ) + """) + op.execute("CREATE INDEX idx_machine_states_line_id ON machine_states(line_id)") + op.execute("CREATE INDEX idx_machine_states_started_at ON machine_states(started_at)") + + # ── oee_snapshots ───────────────────────────────────────────────────────── + op.execute(""" + CREATE TABLE oee_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + line_id UUID NOT NULL REFERENCES lines(id), + work_order_id UUID REFERENCES work_orders(id), + ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + run_time_sec INTEGER NOT NULL DEFAULT 0, + planned_time_sec INTEGER NOT NULL DEFAULT 0, + total_count INTEGER NOT NULL DEFAULT 0, + good_count INTEGER NOT NULL DEFAULT 0, + ideal_cycle_sec FLOAT NOT NULL DEFAULT 1.0, + availability FLOAT NOT NULL DEFAULT 0.0, + performance FLOAT NOT NULL DEFAULT 0.0, + quality FLOAT NOT NULL DEFAULT 0.0, + oee FLOAT NOT NULL DEFAULT 0.0, + teep FLOAT + ) + """) + op.execute("CREATE INDEX idx_oee_snapshots_line_id ON oee_snapshots(line_id)") + op.execute("CREATE INDEX idx_oee_snapshots_ts ON oee_snapshots(ts)") + + # ── Seed: downtime_reasons ──────────────────────────────────────────────── + # 14 standard codes. Add new codes via INSERT — do not remove existing ones. + op.execute(""" + INSERT INTO downtime_reasons (code, description, category) VALUES + ('MAINT_PM', 'Planned preventive maintenance', 'PLANNED'), + ('MAINT_BREAKDOWN', 'Unplanned equipment breakdown', 'UNPLANNED'), + ('CHANGEOVER_PRODUCT', 'Product changeover', 'PLANNED'), + ('CHANGEOVER_TOOLING', 'Tooling change', 'PLANNED'), + ('STARVED_MATERIAL', 'Starved — no material upstream', 'UNPLANNED'), + ('BLOCKED_DOWNSTREAM', 'Blocked — downstream full or stopped', 'UNPLANNED'), + ('QUALITY_HOLD', 'Quality hold — inspection required', 'PLANNED'), + ('E_STOP', 'Emergency stop activated', 'UNPLANNED'), + ('OVERLOAD', 'Motor overload fault', 'UNPLANNED'), + ('OVERHEAT', 'Thermal fault — motor or drive overheat', 'UNPLANNED'), + ('SENSOR_FAIL', 'Sensor failure or miscommunication', 'UNPLANNED'), + ('COMMS_FAIL', 'PLC communications failure', 'UNPLANNED'), + ('JAM', 'Conveyor or mechanism jam', 'UNPLANNED'), + ('UNKNOWN', 'Reason not yet categorized', 'UNPLANNED') + """) + + # ── Seed: lines ─────────────────────────────────────────────────────────── + # Two lines matching the FactoryLM cluster at 192.168.1.100 + op.execute(""" + INSERT INTO lines (name, isa95_path, plc_host, plc_port, description) VALUES + ('Conveyor-1', 'lakewales/floor/conveyor-1', '192.168.1.100', 502, + 'Conveyor of Destiny — From A to B scene (Micro820 + Factory IO)'), + ('Sorting-1', 'lakewales/floor/sorting-1', '192.168.1.100', 502, + 'Sorting by Height scene — Factory IO') + """) + + +def downgrade() -> None: + # Drop tables in reverse dependency order + op.execute("DROP TABLE IF EXISTS oee_snapshots") + op.execute("DROP TABLE IF EXISTS machine_states") + op.execute("DROP TABLE IF EXISTS schedules") + op.execute("DROP TABLE IF EXISTS work_orders") + op.execute("DROP TABLE IF EXISTS downtime_reasons") + op.execute("DROP TABLE IF EXISTS products") + op.execute("DROP TABLE IF EXISTS lines") + + # Drop enum types + op.execute("DROP TYPE IF EXISTS shift_enum") + op.execute("DROP TYPE IF EXISTS entered_by_enum") + op.execute("DROP TYPE IF EXISTS downtime_category") + op.execute("DROP TYPE IF EXISTS machine_state_enum") + op.execute("DROP TYPE IF EXISTS work_order_status") diff --git a/services/mes/backend/__init__.py b/services/mes/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/mes/backend/config.py b/services/mes/backend/config.py new file mode 100644 index 0000000..ed57e54 --- /dev/null +++ b/services/mes/backend/config.py @@ -0,0 +1,26 @@ +"""MES service configuration.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="FACTORYLM_", + env_file=".env", + extra="ignore", + ) + + api_title: str = "FactoryLM MES API" + api_version: str = "0.1.0" + api_prefix: str = "/api" + + # PostgreSQL connection string + # Format: postgresql://user:password@host:port/dbname + database_url: str = "postgresql://mes:meslocal@localhost:5434/mes_core" + + # PLC defaults (overridden per-line from DB) + plc_poll_interval_sec: int = 5 + plc_use_mock: bool = False + + +settings = Settings() diff --git a/services/mes/backend/database.py b/services/mes/backend/database.py new file mode 100644 index 0000000..caa0b70 --- /dev/null +++ b/services/mes/backend/database.py @@ -0,0 +1,28 @@ +"""SQLAlchemy engine, session, and Base for MES service.""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from backend.config import settings + +engine = create_engine( + settings.database_url, + pool_pre_ping=True, # detect stale connections + pool_size=5, + max_overflow=10, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + """FastAPI dependency — yield a DB session, always close on exit.""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/services/mes/backend/main.py b/services/mes/backend/main.py new file mode 100644 index 0000000..722f1d4 --- /dev/null +++ b/services/mes/backend/main.py @@ -0,0 +1,46 @@ +"""FactoryLM MES API — FastAPI entry point. + +Week 1: /health only. +Week 2+: lines, work_orders, downtime, oee routes added here. +""" + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from backend.config import settings +from backend.routes.health import router as health_router + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("MES service starting — DB: %s", settings.database_url.split("@")[-1]) + yield + logger.info("MES service stopping") + + +app = FastAPI( + title=settings.api_title, + version=settings.api_version, + docs_url=f"{settings.api_prefix}/docs", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(health_router, prefix=settings.api_prefix) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("backend.main:app", host="0.0.0.0", port=8300, reload=True) diff --git a/services/mes/backend/models/__init__.py b/services/mes/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/mes/backend/models/db_models.py b/services/mes/backend/models/db_models.py new file mode 100644 index 0000000..94391d4 --- /dev/null +++ b/services/mes/backend/models/db_models.py @@ -0,0 +1,238 @@ +"""SQLAlchemy ORM models — MES Core schema. + +7 tables: + lines — configured production lines + products — SKUs with ideal cycle times for OEE + work_orders — production jobs (create → active → complete) + schedules — shift-based production schedule + downtime_reasons — lookup table of reason codes (seeded in migration) + machine_states — time-series of state transitions per line + oee_snapshots — computed OEE snapshots (60s tick per active line) +""" + +import enum +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + Column, + DateTime, + Enum as SAEnum, + Float, + ForeignKey, + Index, + Integer, + Text, + text, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from backend.database import Base + + +# ── Enum types ──────────────────────────────────────────────────────────────── + +class WorkOrderStatus(str, enum.Enum): + PENDING = "PENDING" + ACTIVE = "ACTIVE" + PAUSED = "PAUSED" + COMPLETE = "COMPLETE" + CANCELLED = "CANCELLED" + + +class MachineStateEnum(str, enum.Enum): + RUNNING = "RUNNING" + DOWN = "DOWN" + CHANGEOVER = "CHANGEOVER" + IDLE = "IDLE" + OFFLINE = "OFFLINE" + + +class DowntimeCategory(str, enum.Enum): + PLANNED = "PLANNED" + UNPLANNED = "UNPLANNED" + EXTERNAL = "EXTERNAL" + + +class EnteredBy(str, enum.Enum): + PLC = "PLC" + OPERATOR = "OPERATOR" + MIRA_AI = "MIRA_AI" + + +class ShiftEnum(str, enum.Enum): + DAY = "DAY" + NIGHT = "NIGHT" + WEEKEND = "WEEKEND" + + +# ── Tables ──────────────────────────────────────────────────────────────────── + +class Line(Base): + """A physical production line (conveyor, cell, etc.).""" + __tablename__ = "lines" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(Text, unique=True, nullable=False) + isa95_path = Column(Text, nullable=False) # e.g. "lakewales/floor/conveyor-1" + plc_host = Column(Text, nullable=False) # e.g. "192.168.1.100" + plc_port = Column(Integer, default=502) + description = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=text("NOW()")) + + work_orders = relationship("WorkOrder", back_populates="line") + machine_states = relationship("MachineState", back_populates="line") + oee_snapshots = relationship("OEESnapshot", back_populates="line") + schedules = relationship("Schedule", back_populates="line") + + +class Product(Base): + """A manufacturable SKU with ideal cycle time for Performance OEE.""" + __tablename__ = "products" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + sku = Column(Text, unique=True, nullable=False) + name = Column(Text, nullable=False) + ideal_cycle_sec = Column(Float, nullable=False) # seconds per part at 100% performance + description = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=text("NOW()")) + + work_orders = relationship("WorkOrder", back_populates="product") + + +class WorkOrder(Base): + """A discrete production job: product + line + target quantity.""" + __tablename__ = "work_orders" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + order_number = Column(Text, unique=True, nullable=False) + product_id = Column(UUID(as_uuid=True), ForeignKey("products.id"), nullable=False) + line_id = Column(UUID(as_uuid=True), ForeignKey("lines.id"), nullable=False) + target_qty = Column(Integer, nullable=False) + good_qty = Column(Integer, default=0, nullable=False) + status = Column( + SAEnum(WorkOrderStatus, name="work_order_status"), + default=WorkOrderStatus.PENDING, + nullable=False, + ) + scheduled_start = Column(DateTime(timezone=True)) + actual_start = Column(DateTime(timezone=True)) + actual_end = Column(DateTime(timezone=True)) + notes = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=text("NOW()")) + updated_at = Column(DateTime(timezone=True), server_default=text("NOW()"), onupdate=datetime.utcnow) + + product = relationship("Product", back_populates="work_orders") + line = relationship("Line", back_populates="work_orders") + oee_snapshots = relationship("OEESnapshot", back_populates="work_order") + + __table_args__ = ( + Index("idx_work_orders_line_id", "line_id"), + Index("idx_work_orders_status", "status"), + ) + + +class Schedule(Base): + """Maps a work order to a shift on a specific line.""" + __tablename__ = "schedules" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + work_order_id = Column(UUID(as_uuid=True), ForeignKey("work_orders.id"), nullable=False) + line_id = Column(UUID(as_uuid=True), ForeignKey("lines.id"), nullable=False) + shift = Column(SAEnum(ShiftEnum, name="shift_enum"), nullable=False) + planned_start = Column(DateTime(timezone=True), nullable=False) + planned_end = Column(DateTime(timezone=True), nullable=False) + planned_qty = Column(Integer, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=text("NOW()")) + + work_order = relationship("WorkOrder") + line = relationship("Line", back_populates="schedules") + + +class DowntimeReason(Base): + """Lookup table of reason codes for machine downtime. + + Seeded in the initial migration with 14 standard codes. + Extensible via INSERT — do not remove existing codes. + """ + __tablename__ = "downtime_reasons" + + code = Column(Text, primary_key=True) # e.g. "MAINT_BREAKDOWN" + description = Column(Text, nullable=False) + category = Column(SAEnum(DowntimeCategory, name="downtime_category"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=text("NOW()")) + + machine_states = relationship("MachineState", back_populates="reason") + + +class MachineState(Base): + """Time-series of machine state transitions per line. + + State machine: + IDLE → RUNNING (motor_running coil = 1) + RUNNING → DOWN (fault_alarm coil = 1) + ANY → OFFLINE (PLC unreachable > 10s) + DOWN / OFFLINE → IDLE (fault cleared, no run command) + """ + __tablename__ = "machine_states" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + line_id = Column(UUID(as_uuid=True), ForeignKey("lines.id"), nullable=False) + state = Column(SAEnum(MachineStateEnum, name="machine_state_enum"), nullable=False) + started_at = Column(DateTime(timezone=True), nullable=False, server_default=text("NOW()")) + ended_at = Column(DateTime(timezone=True)) + reason_code = Column(Text, ForeignKey("downtime_reasons.code")) # NULL when RUNNING/IDLE + entered_by = Column(SAEnum(EnteredBy, name="entered_by_enum"), default=EnteredBy.PLC) + notes = Column(Text) + + line = relationship("Line", back_populates="machine_states") + reason = relationship("DowntimeReason", back_populates="machine_states") + + __table_args__ = ( + Index("idx_machine_states_line_id", "line_id"), + Index("idx_machine_states_started_at", "started_at"), + ) + + +class OEESnapshot(Base): + """OEE computed snapshot — written every 60s per active line. + + OEE = Availability × Performance × Quality + Availability = run_time_sec / planned_time_sec + Performance = (ideal_cycle_sec × total_count) / run_time_sec + Quality = good_count / total_count + TEEP = oee × utilization (scheduled / max possible time) + + PLC register sources (main CLAUDE.md): + motor_running → Coil 0 (state = RUNNING) + fault_alarm → Coil 2 (state = DOWN) + motor_speed → HR100 (0-100, raw) + conveyor_speed → HR104 (0-100, raw) + error_code → HR105 (0=none .. 7=estop) + """ + __tablename__ = "oee_snapshots" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + line_id = Column(UUID(as_uuid=True), ForeignKey("lines.id"), nullable=False) + work_order_id = Column(UUID(as_uuid=True), ForeignKey("work_orders.id")) # NULL if no active WO + ts = Column(DateTime(timezone=True), nullable=False, server_default=text("NOW()")) + run_time_sec = Column(Integer, nullable=False, default=0) + planned_time_sec = Column(Integer, nullable=False, default=0) + total_count = Column(Integer, nullable=False, default=0) + good_count = Column(Integer, nullable=False, default=0) + ideal_cycle_sec = Column(Float, nullable=False, default=1.0) + availability = Column(Float, nullable=False, default=0.0) # 0.0 – 1.0 + performance = Column(Float, nullable=False, default=0.0) # 0.0 – 1.0 + quality = Column(Float, nullable=False, default=0.0) # 0.0 – 1.0 + oee = Column(Float, nullable=False, default=0.0) # 0.0 – 1.0 + teep = Column(Float) # nullable until schedule exists + + line = relationship("Line", back_populates="oee_snapshots") + work_order = relationship("WorkOrder", back_populates="oee_snapshots") + + __table_args__ = ( + Index("idx_oee_snapshots_line_id", "line_id"), + Index("idx_oee_snapshots_ts", "ts"), + ) diff --git a/services/mes/backend/models/mes_models.py b/services/mes/backend/models/mes_models.py new file mode 100644 index 0000000..753886a --- /dev/null +++ b/services/mes/backend/models/mes_models.py @@ -0,0 +1,129 @@ +"""Pydantic UDTs — Walker Reynolds' Week 4 concepts as Pydantic models. + +These are the runtime data shapes published to the Unified Namespace (UNS) +via MQTT and returned by the MES REST API. They map to ORM rows in db_models.py +but are decoupled from SQLAlchemy so they can be used in non-DB contexts. + +Reference: docs/PRD-MES-CORE.md — Section 5 (UDT Definitions) +""" + +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +# ── Walker's three UDTs ─────────────────────────────────────────────────────── + +class LineDataType(BaseModel): + """Complete live snapshot for a single production line. + + Published to UNS topic: {isa95_path}/production/line_data + Updated every 60s by the OEE calculator (Week 3). + """ + line_id: str + isa95_path: str + run_state: Literal["RUNNING", "DOWN", "CHANGEOVER", "IDLE", "OFFLINE"] + oee: float = Field(ge=0.0, le=1.0) + availability: float = Field(ge=0.0, le=1.0) + performance: float = Field(ge=0.0, le=1.0) + quality: float = Field(ge=0.0, le=1.0) + good_count: int = Field(ge=0) + total_count: int = Field(ge=0) + active_work_order_id: Optional[str] = None + ts: datetime + + +class CountDispatch(BaseModel): + """Part count increment event — published on every detected count. + + Published to UNS topic: {isa95_path}/production/count_dispatch + Source: HR100 (ItemCount) delta since last poll. + """ + line_id: str + count_type: Literal["GOOD", "REJECT", "TOTAL"] + delta: int = Field(ge=0, description="Parts counted since last dispatch") + ts: datetime + + +class OEEDataType(BaseModel): + """OEE computation result for a single interval. + + Published to UNS topic: {isa95_path}/production/oee + Written to oee_snapshots table every 60s. + """ + line_id: str + interval_sec: int = Field(gt=0, description="Length of the measurement interval") + availability: float = Field(ge=0.0, le=1.0) + performance: float = Field(ge=0.0, le=1.0) + quality: float = Field(ge=0.0, le=1.0) + oee: float = Field(ge=0.0, le=1.0) + teep: Optional[float] = Field(default=None, ge=0.0, le=1.0) + ts: datetime + + +# ── REST API request / response models ─────────────────────────────────────── + +class LineResponse(BaseModel): + id: str + name: str + isa95_path: str + plc_host: str + plc_port: int + description: Optional[str] = None + + model_config = {"from_attributes": True} + + +class WorkOrderCreate(BaseModel): + order_number: str + product_id: str + line_id: str + target_qty: int = Field(gt=0) + scheduled_start: Optional[datetime] = None + notes: Optional[str] = None + + +class WorkOrderResponse(BaseModel): + id: str + order_number: str + product_id: str + line_id: str + target_qty: int + good_qty: int + status: str + scheduled_start: Optional[datetime] = None + actual_start: Optional[datetime] = None + actual_end: Optional[datetime] = None + notes: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class WorkOrderStatusUpdate(BaseModel): + status: Literal["PENDING", "ACTIVE", "PAUSED", "COMPLETE", "CANCELLED"] + + +class DowntimeEntry(BaseModel): + reason_code: str + entered_by: Literal["OPERATOR", "MIRA_AI"] = "OPERATOR" + notes: Optional[str] = None + + +class OEESummaryItem(BaseModel): + line_id: str + line_name: str + oee: float + availability: float + performance: float + quality: float + teep: Optional[float] = None + run_state: str + ts: Optional[datetime] = None + + +class HealthResponse(BaseModel): + status: str + version: str + db: str # "ok" | "unreachable" diff --git a/services/mes/backend/routes/__init__.py b/services/mes/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/mes/backend/routes/health.py b/services/mes/backend/routes/health.py new file mode 100644 index 0000000..bcd1c94 --- /dev/null +++ b/services/mes/backend/routes/health.py @@ -0,0 +1,28 @@ +"""Health check endpoint.""" + +from fastapi import APIRouter +from sqlalchemy import text + +from backend.config import settings +from backend.database import SessionLocal +from backend.models.mes_models import HealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse, tags=["meta"]) +def health_check(): + """Returns service version and DB connectivity status.""" + db_status = "ok" + try: + db = SessionLocal() + db.execute(text("SELECT 1")) + db.close() + except Exception: + db_status = "unreachable" + + return HealthResponse( + status="healthy", + version=settings.api_version, + db=db_status, + ) diff --git a/services/mes/requirements.txt b/services/mes/requirements.txt new file mode 100644 index 0000000..98f9a01 --- /dev/null +++ b/services/mes/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.100.0 +uvicorn>=0.20.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +sqlalchemy>=2.0.0 +alembic>=1.13.0 +psycopg2-binary>=2.9.0 diff --git a/services/mes/tests/__init__.py b/services/mes/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/mes/tests/conftest.py b/services/mes/tests/conftest.py new file mode 100644 index 0000000..13a2a39 --- /dev/null +++ b/services/mes/tests/conftest.py @@ -0,0 +1,35 @@ +"""Test fixtures for MES service. + +Requires a running Postgres (factorylm-mes-db) or TEST_DATABASE_URL env var. + +Run: + docker compose up factorylm-mes-db -d + cd services/mes + DATABASE_URL="postgresql://mes:meslocal@localhost:5433/mes_core" pytest tests/ -v +""" + +import os +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +# Allow override via env var; fall back to local compose default +DATABASE_URL = os.environ.get( + "FACTORYLM_DATABASE_URL", + "postgresql://mes:meslocal@localhost:5434/mes_core", +) + + +@pytest.fixture(scope="session") +def db_engine(): + engine = create_engine(DATABASE_URL) + yield engine + engine.dispose() + + +@pytest.fixture(scope="session") +def db_session(db_engine): + Session = sessionmaker(bind=db_engine) + session = Session() + yield session + session.close() diff --git a/services/mes/tests/test_schema.py b/services/mes/tests/test_schema.py new file mode 100644 index 0000000..fee491b --- /dev/null +++ b/services/mes/tests/test_schema.py @@ -0,0 +1,136 @@ +"""Week 1 acceptance tests — DB schema verification. + +Acceptance Criteria (from PRD-MES-CORE.md Section 10): + AC#1 alembic upgrade head runs without error (verified by schema existing) + AC#2 All 7 tables present in mes_core schema + AC#3 downtime_reasons has >= 14 seed records + AC#4 lines table has >= 2 seed records (Conveyor-1, Sorting-1) + AC#5 All enum types exist + AC#6 All expected indexes exist +""" + +import pytest +from sqlalchemy import inspect, text + + +EXPECTED_TABLES = { + "lines", + "products", + "work_orders", + "schedules", + "downtime_reasons", + "machine_states", + "oee_snapshots", +} + +EXPECTED_ENUMS = { + "work_order_status", + "machine_state_enum", + "downtime_category", + "entered_by_enum", + "shift_enum", +} + +EXPECTED_INDEXES = [ + ("work_orders", "idx_work_orders_line_id"), + ("work_orders", "idx_work_orders_status"), + ("machine_states", "idx_machine_states_line_id"), + ("machine_states", "idx_machine_states_started_at"), + ("oee_snapshots", "idx_oee_snapshots_line_id"), + ("oee_snapshots", "idx_oee_snapshots_ts"), +] + +SEED_DOWNTIME_CODES = { + "MAINT_PM", "MAINT_BREAKDOWN", "CHANGEOVER_PRODUCT", "CHANGEOVER_TOOLING", + "STARVED_MATERIAL", "BLOCKED_DOWNSTREAM", "QUALITY_HOLD", "E_STOP", + "OVERLOAD", "OVERHEAT", "SENSOR_FAIL", "COMMS_FAIL", "JAM", "UNKNOWN", +} + +SEED_LINES = {"Conveyor-1", "Sorting-1"} + + +class TestTables: + def test_all_seven_tables_exist(self, db_engine): + """AC#2 — all 7 MES tables present.""" + inspector = inspect(db_engine) + actual = set(inspector.get_table_names()) + missing = EXPECTED_TABLES - actual + assert not missing, f"Missing tables: {missing}" + + def test_lines_columns(self, db_engine): + inspector = inspect(db_engine) + cols = {c["name"] for c in inspector.get_columns("lines")} + assert {"id", "name", "isa95_path", "plc_host", "plc_port", "created_at"} <= cols + + def test_work_orders_columns(self, db_engine): + inspector = inspect(db_engine) + cols = {c["name"] for c in inspector.get_columns("work_orders")} + assert { + "id", "order_number", "product_id", "line_id", + "target_qty", "good_qty", "status", "created_at", + } <= cols + + def test_oee_snapshots_columns(self, db_engine): + inspector = inspect(db_engine) + cols = {c["name"] for c in inspector.get_columns("oee_snapshots")} + assert { + "id", "line_id", "work_order_id", "ts", + "availability", "performance", "quality", "oee", "teep", + } <= cols + + def test_machine_states_columns(self, db_engine): + inspector = inspect(db_engine) + cols = {c["name"] for c in inspector.get_columns("machine_states")} + assert {"id", "line_id", "state", "started_at", "reason_code", "entered_by"} <= cols + + +class TestSeedData: + def test_downtime_reasons_count(self, db_session): + """AC#3 — at least 14 seed records in downtime_reasons.""" + result = db_session.execute(text("SELECT COUNT(*) FROM downtime_reasons")).scalar() + assert result >= 14, f"Expected >= 14 downtime_reasons, got {result}" + + def test_downtime_reasons_codes(self, db_session): + """All 14 standard codes present.""" + rows = db_session.execute(text("SELECT code FROM downtime_reasons")).fetchall() + actual_codes = {r[0] for r in rows} + missing = SEED_DOWNTIME_CODES - actual_codes + assert not missing, f"Missing downtime_reason codes: {missing}" + + def test_lines_seeded(self, db_session): + """AC#4 — Conveyor-1 and Sorting-1 present.""" + rows = db_session.execute(text("SELECT name FROM lines")).fetchall() + actual_names = {r[0] for r in rows} + missing = SEED_LINES - actual_names + assert not missing, f"Missing seeded lines: {missing}" + + def test_lines_isa95_paths(self, db_session): + rows = db_session.execute( + text("SELECT name, isa95_path, plc_host FROM lines") + ).fetchall() + by_name = {r[0]: r for r in rows} + assert "Conveyor-1" in by_name + assert by_name["Conveyor-1"][1] == "lakewales/floor/conveyor-1" + assert by_name["Conveyor-1"][2] == "192.168.1.100" + + +class TestEnums: + def test_enum_types_exist(self, db_session): + """AC#5 — all 5 PostgreSQL enum types created.""" + result = db_session.execute( + text("SELECT typname FROM pg_type WHERE typcategory = 'E'") + ).fetchall() + actual_enums = {r[0] for r in result} + missing = EXPECTED_ENUMS - actual_enums + assert not missing, f"Missing enum types: {missing}" + + +class TestIndexes: + def test_indexes_exist(self, db_engine): + """AC#6 — all 6 performance indexes present.""" + inspector = inspect(db_engine) + for table_name, index_name in EXPECTED_INDEXES: + indexes = {i["name"] for i in inspector.get_indexes(table_name)} + assert index_name in indexes, ( + f"Missing index {index_name} on {table_name}" + )