Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 61 additions & 55 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -1,86 +1,92 @@
# PLAN: MES Core — Week 2 (Modbus Machine State Reader)
# PLAN: MES Core — Week 4 (Work Orders + Scheduling + TEEP)

**Branch:** `feat/mes-week2-state-reader`
**Issue:** Mikecranesync/MIRA#320
**Branch:** `feat/mes-week4-work-orders`
**Issue:** Mikecranesync/MIRA#322
**PRD:** `docs/PRD-MES-CORE.md`
**Date:** 2026-04-15
**Depends on:** Week 1 (feat/mes-week1-db-schema) merged
**Date:** 2026-04-16
**Depends on:** Week 3 (feat/mes-week3-oee-calculator) merged

---

## Objective

Build the machine state reader: a background poller that reads the plc-modbus HTTP API every 5 seconds per configured line, detects state transitions (RUNNING/DOWN/IDLE/OFFLINE), writes them to `machine_states`, and exposes `GET /api/mes/lines` and `GET /api/mes/lines/{id}/state` REST endpoints.
Wire work orders into the MES: create/list/detail/transition endpoints,
expose schedule-aware TEEP (utilisation = scheduled_time / calendar_time),
and update Pydantic UDTs (LineDataType, CountDispatch, OEEDataType) to be
the standard payload shape across all MES responses.

## Affected Files

**New:**
- `services/mes/backend/services/__init__.py`
- `services/mes/backend/services/plc_client.py` — async HTTP client wrapping plc-modbus
- `services/mes/backend/services/state_machine.py` — pure state detection from IO snapshot
- `services/mes/backend/services/state_poller.py` — asyncio background poll loop
- `services/mes/backend/routes/lines.py` — GET /api/mes/lines, GET /lines/{id}/state
- `services/mes/tests/test_machine_states.py` — 10 unit tests, all mocked
- `services/mes/backend/routes/work_orders.py` — CRUD + status transitions
- `services/mes/tests/test_work_orders.py` — unit tests (mocked DB)

**Modified:**
- `services/mes/requirements.txt` — add httpx
- `services/mes/backend/config.py` — add plc_modbus_url setting
- `services/mes/backend/main.py` — wire poller into lifespan, add lines router
- `docker-compose.yml` — add PLC_MODBUS_URL env to mes container
- `services/mes/backend/models/mes_models.py` — finalise UDTs, add WorkOrder schemas
- `services/mes/backend/services/oee_calculator.py` — TEEP uses schedule utilisation
- `services/mes/backend/main.py` — include work_orders router
- `PLAN.md` — this file

---

## Approach

1. `plc_client.py` — thin async wrapper around `GET /api/plc/io` (httpx). Raises `PLCOfflineError` on timeout/connection failure so caller can set OFFLINE state.
2. `state_machine.py` — pure function `detect_state(io_data)` → `(MachineStateEnum, reason_code | None)`. Derived from `VFDStatus` and `ErrorCode` registers. No DB or network calls — fully testable without mocks.
3. `state_poller.py` — asyncio task, one iteration per line every 5s. Maintains in-memory cache to avoid DB reads on every tick. Writes to `machine_states` only on transition.
4. `lines.py` routes — two endpoints: list all lines (from DB), get current state (from in-memory cache + last DB row).
5. `main.py` lifespan — start poller task on startup, cancel on shutdown.
### 1. Work Order Routes (`work_orders.py`)

State transition write: close open row (`ended_at = NOW()`), insert new row.
Four endpoints:

## State Machine
| Method | Path | Action |
|--------|------|--------|
| POST | `/api/mes/work-orders` | Create — status defaults to PENDING |
| GET | `/api/mes/work-orders` | List — filter by `?line_id=` and/or `?status=` |
| GET | `/api/mes/work-orders/{id}` | Detail |
| PATCH | `/api/mes/work-orders/{id}/status` | Transition: PENDING→ACTIVE→COMPLETE / CANCELLED |

```
IO: VFDStatus=1, ErrorCode=0 → RUNNING
IO: VFDStatus=2 OR ErrorCode>0 → DOWN (reason_code from ErrorCode map)
IO: VFDStatus=0, ErrorCode=0 → IDLE
HTTP failure / timeout → OFFLINE
```
Transition rules (enforced server-side):
- PENDING → ACTIVE (start the job)
- ACTIVE → COMPLETE (job done)
- ACTIVE → CANCELLED
- PENDING → CANCELLED

## ErrorCode → reason_code map
One line can only have **one ACTIVE work order at a time** — enforced with a
409 Conflict response.

```python
{1: "OVERLOAD", 2: "OVERHEAT", 3: "SENSOR_FAIL", 4: "JAM", 7: "E_STOP"}
```
### 2. Schedule-Aware TEEP

## Risks
TEEP = OEE × Utilisation
Utilisation = scheduled_minutes_in_period / calendar_minutes_in_period

- plc-modbus in mock mode returns VFDStatus=0 at rest — poller sees IDLE immediately (expected)
- Multiple lines share one plc-modbus service currently — same io_data, different `line_id` rows
`compute_oee()` gains an optional `utilisation` param (default 1.0, preserving
Week 3 behaviour). The tick loop queries the `schedules` table for the active
shift and passes the utilisation factor.

## Rollback
Until schedules are seeded, utilisation stays 1.0 — no regression.

```bash
git checkout feat/mes-week1-db-schema
```
### 3. Pydantic UDTs (mes_models.py)

## Verification Steps
Finalise:
- `LineDataType` — full live status payload
- `CountDispatch` — part count event
- `OEEDataType` — OEE snapshot shape (matches DB + API)
- `WorkOrderCreate`, `WorkOrderResponse`, `WorkOrderStatusUpdate`

```bash
# Unit tests (no docker needed)
cd services/mes && pytest tests/test_machine_states.py -v
---

# Integration: start stack, check state endpoint
docker compose up mes-db mes plc-modbus -d
curl localhost:8300/api/mes/lines
curl localhost:8300/api/mes/lines/<id>/state
## Risks

- One-ACTIVE-per-line constraint must be checked atomically — use DB query
inside the same transaction, not an in-memory cache.
- `compute_oee()` signature change adds `utilisation` param — must be
keyword-only with a default so Week 3 callers need zero changes.

# Inject a fault and verify DB transition
curl -X POST localhost:8001/api/plc/mock/fault -H "Content-Type: application/json" -d '{"fault_type":"jam"}'
sleep 8
curl localhost:8300/api/mes/lines/<id>/state # should show DOWN / JAM
```
## Rollback

## Note on Active Focus Window
Revert this branch. No DB migrations needed — all tables were created in
Week 1. Work order data is additive.

## Verification Steps

Explicitly authorized by Mike (2026-04-15 session).
1. `pytest tests/test_work_orders.py -v` — all new tests pass
2. `pytest tests/ -v` — full suite (48 + new) passes, zero regressions
3. Manually: POST work order → PATCH to ACTIVE → OEE tick uses correct ideal_cycle_sec
4. Manually: attempt second ACTIVE work order on same line → 409
5 changes: 4 additions & 1 deletion services/mes/backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ class Settings(BaseSettings):
# Polling interval in seconds (default 5, set lower in tests)
plc_poll_interval_sec: int = 5

# Set True to skip poller startup (useful in unit tests)
# OEE calculator tick interval in seconds (default 60)
oee_tick_sec: int = 60

# Set True to skip background task startup (useful in unit tests)
plc_use_mock: bool = False


Expand Down
45 changes: 32 additions & 13 deletions services/mes/backend/main.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""FactoryLM MES API — FastAPI entry point.

Lifespan:
startup → seed state cache, launch background state poller
shutdown → signal poller to stop cleanly
startup → launch state poller + OEE calculator background tasks
shutdown → stop both tasks cleanly

Routes (cumulative by week):
Week 1: /api/health
Week 2: /api/mes/lines, /api/mes/lines/{id}/state
Week 3: /api/mes/lines/{id}/oee, /api/mes/lines/{id}/oee/history
/api/mes/oee/summary, /api/mes/kpis
Week 4: /api/mes/products, /api/mes/products (POST/GET)
/api/mes/work-orders (POST/GET), /api/mes/work-orders/{id} (GET)
/api/mes/work-orders/{id}/status (PATCH)
Schedule-aware TEEP via schedules table
"""

import asyncio
Expand All @@ -19,7 +25,9 @@
from backend.config import settings
from backend.routes.health import router as health_router
from backend.routes.lines import router as lines_router
from backend.services import state_poller
from backend.routes.oee import router as oee_router
from backend.routes.work_orders import router as work_orders_router
from backend.services import oee_calculator, state_poller

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
Expand All @@ -30,25 +38,34 @@ async def lifespan(app: FastAPI):
db_host = settings.database_url.split("@")[-1]
logger.info("MES service starting — DB: %s PLC: %s", db_host, settings.plc_modbus_url)

poller_task = None
poller_task = oee_task = None

if not settings.plc_use_mock:
poller_task = asyncio.create_task(
state_poller.run(poll_interval_sec=settings.plc_poll_interval_sec),
name="state_poller",
)
logger.info("State poller started (interval=%ds)", settings.plc_poll_interval_sec)
oee_task = asyncio.create_task(
oee_calculator.run(tick_sec=settings.oee_tick_sec),
name="oee_calculator",
)
logger.info(
"Background tasks started — poller=%ds oee_tick=%ds",
settings.plc_poll_interval_sec, settings.oee_tick_sec,
)
else:
logger.info("PLC mock mode — state poller disabled")
logger.info("PLC mock mode — background tasks disabled")

yield

logger.info("MES service shutting down")
if poller_task:
state_poller.stop()
state_poller.stop()
oee_calculator.stop()
for task in [t for t in [poller_task, oee_task] if t]:
try:
await asyncio.wait_for(poller_task, timeout=8.0)
except asyncio.TimeoutError:
poller_task.cancel()
await asyncio.wait_for(task, timeout=8.0)
except (asyncio.TimeoutError, asyncio.CancelledError):
task.cancel()


app = FastAPI(
Expand All @@ -65,8 +82,10 @@ async def lifespan(app: FastAPI):
allow_headers=["*"],
)

app.include_router(health_router, prefix=settings.api_prefix)
app.include_router(lines_router, prefix=settings.api_prefix)
app.include_router(health_router, prefix=settings.api_prefix)
app.include_router(lines_router, prefix=settings.api_prefix)
app.include_router(oee_router, prefix=settings.api_prefix)
app.include_router(work_orders_router, prefix=settings.api_prefix)


if __name__ == "__main__":
Expand Down
Loading
Loading