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
92 changes: 21 additions & 71 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -1,86 +1,36 @@
# PLAN: MES Core — Week 2 (Modbus Machine State Reader)
# PLAN: MES Core — Week 3 (OEE Calculator Service)

**Branch:** `feat/mes-week2-state-reader`
**Issue:** Mikecranesync/MIRA#320
**Branch:** `feat/mes-week3-oee-calculator`
**Issue:** Mikecranesync/MIRA#321
**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:** Weeks 1+2 merged to main ✓

---

## 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.
60-second tick OEE calculator. Reads ItemCount delta from plc-modbus,
computes Availability/Performance/Quality/OEE/TEEP from machine_states,
writes to oee_snapshots, exposes REST endpoints, and alerts when OEE < 60%.

## Affected Files
## OEE Formula

**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
Availability = run_time_sec / planned_time_sec
Performance = (ideal_cycle_sec x total_count) / max(run_time_sec, 1)
Quality = good_count / max(total_count, 1)
OEE = Availability x Performance x Quality
TEEP = OEE (no schedule yet; Week 4 wires utilization)

**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
Clamp all values to [0.0, 1.0].

## Approach
## New Endpoints

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.

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

## State Machine

```
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
```

## ErrorCode → reason_code map

```python
{1: "OVERLOAD", 2: "OVERHEAT", 3: "SENSOR_FAIL", 4: "JAM", 7: "E_STOP"}
```

## Risks

- 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
GET /api/mes/lines/{id}/oee
GET /api/mes/lines/{id}/oee/history?hours=8
GET /api/mes/oee/summary
GET /api/mes/kpis

## Rollback

```bash
git checkout feat/mes-week1-db-schema
```

## Verification Steps

```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

# 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
```

## Note on Active Focus Window

Explicitly authorized by Mike (2026-04-15 session).
git checkout main
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
35 changes: 24 additions & 11 deletions services/mes/backend/main.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""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
"""

import asyncio
Expand All @@ -19,7 +21,8 @@
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.services import oee_calculator, state_poller

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
Expand All @@ -30,25 +33,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 @@ -67,6 +79,7 @@ async def lifespan(app: FastAPI):

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)


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