Initial Plast Track MVP

This commit is contained in:
masterdev
2026-06-15 13:35:22 +01:00
commit 3bf4c622d8
47 changed files with 7339 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Backend service modules."""

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import datetime, time, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..models import Cycle, Downtime, Machine, ProductionOrder, Scrap
from .events import get_open_downtime, get_running_order
from .oee import compute_machine_oee
UTC = timezone.utc
def _today_bounds() -> tuple[datetime, datetime]:
start = datetime.combine(datetime.now(UTC).date(), time.min, tzinfo=UTC)
return start, start + timedelta(days=1)
def serialize_machine_config(machine: Machine) -> dict:
return {
"name": machine.name,
"brand": machine.brand,
"model": machine.model,
"is_active": machine.is_active,
"cycle_signal_edge": machine.cycle_signal_edge,
"debounce_ms": machine.debounce_ms,
"min_cycle_time_sec": machine.min_cycle_time_sec,
"max_cycle_time_sec": machine.max_cycle_time_sec,
"stop_detection_delay_sec": machine.stop_detection_delay_sec,
}
def serialize_machine_card(session: Session, machine: Machine) -> dict:
running_order = get_running_order(session, machine.id)
open_downtime = get_open_downtime(session, machine.id)
today_start, today_end = _today_bounds()
recent_cycles = session.execute(
select(Cycle.cycle_time_sec).where(Cycle.machine_id == machine.id).order_by(Cycle.timestamp.desc()).limit(20)
).scalars().all()
avg_cycle = sum(recent_cycles) / len(recent_cycles) if recent_cycles else 0
order_produced_qty = 0
order_scrap_qty = 0
progress = 0
if running_order is not None:
order_produced_qty = session.execute(
select(func.coalesce(func.sum(Cycle.produced_qty), 0)).where(
Cycle.production_order_id == running_order.id,
Cycle.is_valid.is_(True),
)
).scalar_one()
order_scrap_qty = session.execute(
select(func.coalesce(func.sum(Scrap.quantity), 0)).where(Scrap.production_order_id == running_order.id)
).scalar_one()
progress = min(order_produced_qty / running_order.planned_qty, 1) if running_order.planned_qty else 0
daily_oee = compute_machine_oee(session, machine.id, today_start, today_end)
latest_downtime = session.execute(
select(Downtime).where(Downtime.machine_id == machine.id).order_by(Downtime.start_time.desc()).limit(1)
).scalar_one_or_none()
return {
"id": machine.id,
"code": machine.code,
"name": machine.name,
"brand": machine.brand,
"model": machine.model,
"status": machine.status,
"status_since": machine.status_since.isoformat() if machine.status_since else None,
"powered_on": machine.powered_on,
"config": serialize_machine_config(machine),
"active_order": None
if running_order is None
else {
"id": running_order.id,
"order_number": running_order.order_number,
"article_ref": running_order.article_ref,
"article_name": running_order.article_name,
"mold_ref": running_order.mold_ref,
"material_ref": running_order.material_ref,
"planned_qty": running_order.planned_qty,
"cavities": running_order.cavities,
"theoretical_cycle_time_sec": running_order.theoretical_cycle_time_sec,
"status": running_order.status,
},
"cycle_real_avg_sec": round(avg_cycle, 2),
"produced_qty": int(order_produced_qty),
"scrap_qty": int(order_scrap_qty),
"order_progress": round(progress, 4),
"today_oee": daily_oee,
"open_downtime": None
if open_downtime is None
else {
"id": open_downtime.id,
"reason_code": open_downtime.reason_code,
"start_time": open_downtime.start_time.isoformat(),
"comment": open_downtime.comment,
},
"last_downtime": None
if latest_downtime is None
else {
"id": latest_downtime.id,
"reason_code": latest_downtime.reason_code,
"start_time": latest_downtime.start_time.isoformat(),
"end_time": latest_downtime.end_time.isoformat() if latest_downtime.end_time else None,
"duration_sec": latest_downtime.duration_sec,
},
}
def build_dashboard_snapshot(session: Session) -> dict:
machines = session.execute(select(Machine).where(Machine.is_active.is_(True)).order_by(Machine.code)).scalars().all()
return {
"timestamp": datetime.now(UTC).isoformat(),
"machines": [serialize_machine_card(session, machine) for machine in machines],
}

View File

@@ -0,0 +1,295 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import Cycle, Downtime, Machine, MachineEvent, ProductionOrder
from ..schemas import MachineEventIn
REGALAGE_REASON_CODES = {"reglage", "changement_moule"}
SIGNAL_EVENT_TYPE = "digital_input_changed"
POWER_INPUT_NAME = "machine_power_on"
CYCLE_INPUT_NAME = "cycle_signal"
UTC = timezone.utc
def _ensure_utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def parse_machine_event(payload: str | bytes) -> MachineEventIn:
raw = payload.decode("utf-8") if isinstance(payload, bytes) else payload
data = json.loads(raw)
return MachineEventIn.model_validate(data)
def normalize_signal_value(value: bool | int | str | None) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
normalized = str(value).strip().lower()
if normalized in {"1", "true", "on", "high"}:
return True
if normalized in {"0", "false", "off", "low"}:
return False
return None
def _get_or_create_machine(session: Session, machine_code: str) -> Machine:
machine = session.execute(select(Machine).where(Machine.code == machine_code)).scalar_one_or_none()
if machine is None:
machine = Machine(code=machine_code, name=machine_code, status="hors_planning")
session.add(machine)
session.flush()
return machine
def get_running_order(session: Session, machine_id: int) -> ProductionOrder | None:
return session.execute(
select(ProductionOrder).where(
ProductionOrder.machine_id == machine_id,
ProductionOrder.status == "running",
).order_by(ProductionOrder.started_at.desc())
).scalar_one_or_none()
def get_open_downtime(session: Session, machine_id: int) -> Downtime | None:
return session.execute(
select(Downtime).where(
Downtime.machine_id == machine_id,
Downtime.end_time.is_(None),
).order_by(Downtime.start_time.desc())
).scalar_one_or_none()
def get_last_input_event(session: Session, machine_id: int, input_name: str) -> MachineEvent | None:
rows = session.execute(
select(MachineEvent).where(
MachineEvent.machine_id == machine_id,
MachineEvent.event_type == SIGNAL_EVENT_TYPE,
).order_by(MachineEvent.timestamp.desc()).limit(50)
).scalars()
for row in rows:
if row.payload.get("input_name") == input_name:
return row
return None
def derive_machine_status(machine: Machine, open_downtime: Downtime | None, running_order: ProductionOrder | None) -> str:
if not machine.powered_on or running_order is None:
return "hors_planning"
if open_downtime is None:
return "production"
if open_downtime.reason_code in REGALAGE_REASON_CODES:
return "reglage"
if open_downtime.reason_code:
return "arret_qualifie"
return "arret_non_qualifie"
def open_downtime_if_needed(session: Session, machine: Machine, event_time: datetime, auto_detected: bool = True) -> Downtime | None:
running_order = get_running_order(session, machine.id)
if running_order is None or not machine.powered_on:
machine.status = "hors_planning"
machine.status_since = event_time
return None
open_downtime = get_open_downtime(session, machine.id)
if open_downtime is not None:
return open_downtime
downtime = Downtime(
machine_id=machine.id,
production_order_id=running_order.id,
start_time=event_time,
auto_detected=auto_detected,
)
session.add(downtime)
machine.status = "arret_non_qualifie"
machine.status_since = event_time
session.flush()
return downtime
def close_open_downtime(session: Session, machine: Machine, event_time: datetime) -> Downtime | None:
downtime = get_open_downtime(session, machine.id)
if downtime is None:
return None
downtime.end_time = event_time
downtime.duration_sec = max(int((event_time - downtime.start_time).total_seconds()), 0)
machine.status_since = event_time
session.flush()
return downtime
def _is_matching_edge(previous_value: bool | None, current_value: bool | None, configured_edge: str) -> bool:
if previous_value is None or current_value is None:
return False
if configured_edge == "falling":
return previous_value is True and current_value is False
return previous_value is False and current_value is True
def _persist_cycle(
session: Session,
machine: Machine,
running_order: ProductionOrder | None,
event_time: datetime,
cycle_time_sec: float,
) -> None:
cavities = running_order.cavities if running_order else 1
is_valid = machine.min_cycle_time_sec <= cycle_time_sec <= machine.max_cycle_time_sec
session.add(
Cycle(
machine_id=machine.id,
production_order_id=running_order.id if running_order else None,
timestamp=event_time,
cycle_time_sec=cycle_time_sec,
cavities=cavities,
produced_qty=cavities if is_valid else 0,
is_valid=is_valid,
)
)
if cycle_time_sec >= machine.min_cycle_time_sec:
machine.last_cycle_at = event_time
machine.status_since = event_time
def _handle_cycle_signal_event(
session: Session,
machine: Machine,
running_order: ProductionOrder | None,
incoming_event: MachineEventIn,
event_time: datetime,
previous_signal_event: MachineEvent | None,
) -> None:
input_value = normalize_signal_value(incoming_event.input_value)
previous_value = None
previous_signal_time = None
if previous_signal_event is not None:
previous_value = normalize_signal_value(previous_signal_event.payload.get("input_value"))
previous_signal_time = _ensure_utc(previous_signal_event.timestamp)
if input_value is None or not _is_matching_edge(previous_value, input_value, machine.cycle_signal_edge):
return
if previous_signal_time is not None:
elapsed_ms = (event_time - previous_signal_time).total_seconds() * 1000
if elapsed_ms < machine.debounce_ms:
return
close_open_downtime(session, machine, event_time)
if machine.last_cycle_at is None:
fallback_cycle_time = incoming_event.cycle_time_sec
if fallback_cycle_time and fallback_cycle_time >= machine.min_cycle_time_sec:
_persist_cycle(session, machine, running_order, event_time, float(fallback_cycle_time))
else:
machine.last_cycle_at = event_time
machine.status_since = event_time
return
cycle_time_sec = (event_time - _ensure_utc(machine.last_cycle_at)).total_seconds()
_persist_cycle(session, machine, running_order, event_time, cycle_time_sec)
def handle_machine_event(session: Session, incoming_event: MachineEventIn) -> dict:
event_time = _ensure_utc(incoming_event.timestamp)
machine = _get_or_create_machine(session, incoming_event.machine_id)
previous_signal_event = None
should_refresh = False
if incoming_event.event_type == SIGNAL_EVENT_TYPE and incoming_event.input_name:
previous_signal_event = get_last_input_event(session, machine.id, incoming_event.input_name)
session.add(
MachineEvent(
machine_id=machine.id,
event_type=incoming_event.event_type,
timestamp=event_time,
payload=incoming_event.model_dump(mode="json"),
)
)
running_order = get_running_order(session, machine.id)
if incoming_event.event_type == "machine_power_on":
machine.powered_on = True
should_refresh = True
elif incoming_event.event_type == "machine_power_off":
machine.powered_on = False
close_open_downtime(session, machine, event_time)
should_refresh = True
elif incoming_event.event_type == "machine_stopped":
open_downtime_if_needed(session, machine, event_time, auto_detected=True)
should_refresh = True
elif incoming_event.event_type == "cycle_completed":
close_open_downtime(session, machine, event_time)
cycle_time_sec = float(incoming_event.cycle_time_sec or 0)
_persist_cycle(session, machine, running_order, event_time, cycle_time_sec)
should_refresh = True
elif incoming_event.event_type == SIGNAL_EVENT_TYPE:
input_name = incoming_event.input_name
input_value = normalize_signal_value(incoming_event.input_value)
if input_name == POWER_INPUT_NAME and input_value is not None:
machine.powered_on = input_value
if not input_value:
close_open_downtime(session, machine, event_time)
should_refresh = True
elif input_name == CYCLE_INPUT_NAME:
previous_cycle_at = machine.last_cycle_at
_handle_cycle_signal_event(session, machine, running_order, incoming_event, event_time, previous_signal_event)
should_refresh = machine.last_cycle_at != previous_cycle_at
elif input_name == "general_alarm" and input_value:
should_refresh = True
machine.status = derive_machine_status(machine, get_open_downtime(session, machine.id), running_order)
session.commit()
return {
"machine_code": machine.code,
"machine_id": machine.id,
"event_type": incoming_event.event_type,
"timestamp": event_time.isoformat(),
"status": machine.status,
"should_refresh": should_refresh,
}
def detect_missing_cycles(session: Session, now: datetime | None = None) -> list[dict]:
current_time = _ensure_utc(now) if now else datetime.now(UTC)
machines = session.execute(select(Machine).where(Machine.is_active.is_(True))).scalars().all()
changes: list[dict] = []
for machine in machines:
running_order = get_running_order(session, machine.id)
if running_order is None or not machine.powered_on or machine.last_cycle_at is None:
continue
delay_sec = max(machine.stop_detection_delay_sec, 1)
last_cycle_at = _ensure_utc(machine.last_cycle_at)
elapsed = (current_time - last_cycle_at).total_seconds()
if elapsed < delay_sec:
continue
downtime = get_open_downtime(session, machine.id)
if downtime is not None:
continue
downtime_start = last_cycle_at + timedelta(seconds=delay_sec)
open_downtime_if_needed(session, machine, downtime_start, auto_detected=True)
machine.status = "arret_non_qualifie"
machine.status_since = current_time
changes.append({"machine_code": machine.code, "event_type": "machine_stopped", "status": machine.status})
if changes:
session.commit()
else:
session.rollback()
return changes

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import logging
import threading
import paho.mqtt.client as mqtt
from ..config import get_settings
from ..database import SessionLocal
from .events import handle_machine_event, parse_machine_event
logger = logging.getLogger(__name__)
class MqttListener:
def __init__(self, notify_callback) -> None:
self.settings = get_settings()
self.notify_callback = notify_callback
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.settings.mqtt_client_id)
self.client.on_connect = self.on_connect
self.client.on_message = self.on_message
self.thread: threading.Thread | None = None
def start(self) -> None:
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def stop(self) -> None:
try:
self.client.disconnect()
except Exception:
logger.exception("MQTT disconnect failed")
def _run(self) -> None:
self.client.connect(self.settings.mqtt_host, self.settings.mqtt_port, 60)
self.client.loop_forever()
def on_connect(self, client, userdata, flags, reason_code, properties) -> None:
logger.info("Connected to MQTT with code %s", reason_code)
client.subscribe(self.settings.mqtt_topic)
def on_message(self, client, userdata, message) -> None:
try:
incoming_event = parse_machine_event(message.payload)
with SessionLocal() as session:
result = handle_machine_event(session, incoming_event)
if result.pop("should_refresh", False):
self.notify_callback({"type": "refresh", "source": "mqtt", **result})
except Exception:
logger.exception("Failed to handle MQTT message on topic %s", message.topic)

202
backend/app/services/oee.py Normal file
View File

@@ -0,0 +1,202 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..models import Cycle, Downtime, ProductionOrder, Scrap
UTC = timezone.utc
@dataclass
class OeeTotals:
planned_time_sec: float = 0
downtime_sec: float = 0
operating_time_sec: float = 0
total_cycles: int = 0
theoretical_cycle_time_sec: float = 0
performance_time_sec: float = 0
total_produced_qty: int = 0
good_qty: int = 0
scrap_qty: int = 0
availability: float = 0
performance: float = 0
quality: float = 0
oee: float = 0
def as_dict(self) -> dict[str, float | int]:
return {
"planned_time_sec": round(self.planned_time_sec, 2),
"downtime_sec": round(self.downtime_sec, 2),
"operating_time_sec": round(self.operating_time_sec, 2),
"total_cycles": self.total_cycles,
"theoretical_cycle_time_sec": round(self.theoretical_cycle_time_sec, 2),
"total_produced_qty": self.total_produced_qty,
"good_qty": self.good_qty,
"scrap_qty": self.scrap_qty,
"availability": round(self.availability, 4),
"performance": round(self.performance, 4),
"quality": round(self.quality, 4),
"oee": round(self.oee, 4),
}
def calculate_oee_metrics(
*,
planned_time_sec: float,
downtime_sec: float,
total_cycles: int,
performance_time_sec: float,
theoretical_cycle_time_sec: float,
total_produced_qty: int,
scrap_qty: int,
) -> OeeTotals:
operating_time_sec = max(planned_time_sec - downtime_sec, 0)
good_qty = max(total_produced_qty - scrap_qty, 0)
availability = operating_time_sec / planned_time_sec if planned_time_sec > 0 else 0
performance = performance_time_sec / operating_time_sec if operating_time_sec > 0 else 0
performance = min(max(performance, 0), 1)
quality = good_qty / total_produced_qty if total_produced_qty > 0 else 0
oee = availability * performance * quality
return OeeTotals(
planned_time_sec=planned_time_sec,
downtime_sec=downtime_sec,
operating_time_sec=operating_time_sec,
total_cycles=total_cycles,
theoretical_cycle_time_sec=theoretical_cycle_time_sec,
performance_time_sec=performance_time_sec,
total_produced_qty=total_produced_qty,
good_qty=good_qty,
scrap_qty=scrap_qty,
availability=availability,
performance=performance,
quality=quality,
oee=oee,
)
def overlap_seconds(window_start: datetime, window_end: datetime, start: datetime, end: datetime | None) -> float:
actual_end = end or window_end
overlap_start = max(window_start, start)
overlap_end = min(window_end, actual_end)
if overlap_end <= overlap_start:
return 0
return (overlap_end - overlap_start).total_seconds()
def compute_machine_oee(session: Session, machine_id: int, window_start: datetime, window_end: datetime) -> dict:
current_time = datetime.now(UTC)
effective_window_end = min(window_end, current_time)
if effective_window_end <= window_start:
effective_window_end = window_end
order_rows = session.execute(
select(ProductionOrder).where(
ProductionOrder.machine_id == machine_id,
ProductionOrder.started_at.is_not(None),
ProductionOrder.started_at < effective_window_end,
func.coalesce(ProductionOrder.ended_at, effective_window_end) > window_start,
)
).scalars()
planned_time_sec = 0.0
for order in order_rows:
effective_order_end = min(order.ended_at, effective_window_end) if order.ended_at else effective_window_end
planned_time_sec += overlap_seconds(window_start, effective_window_end, order.started_at, effective_order_end)
cycle_rows = session.execute(
select(
Cycle.production_order_id,
func.count(Cycle.id),
func.coalesce(func.sum(Cycle.produced_qty), 0),
).where(
Cycle.machine_id == machine_id,
Cycle.timestamp >= window_start,
Cycle.timestamp <= effective_window_end,
Cycle.is_valid.is_(True),
).group_by(Cycle.production_order_id)
).all()
total_cycles = sum(int(row[1]) for row in cycle_rows)
total_produced_qty = sum(int(row[2]) for row in cycle_rows)
performance_time_sec = 0.0
weighted_theoretical = 0.0
counted_cycles = 0
for production_order_id, cycle_count, _ in cycle_rows:
if production_order_id is None:
continue
order = session.get(ProductionOrder, production_order_id)
if order is None:
continue
performance_time_sec += order.theoretical_cycle_time_sec * int(cycle_count)
weighted_theoretical += order.theoretical_cycle_time_sec * int(cycle_count)
counted_cycles += int(cycle_count)
theoretical_cycle_time_sec = weighted_theoretical / counted_cycles if counted_cycles > 0 else 0
downtime_rows = session.execute(
select(Downtime).where(
Downtime.machine_id == machine_id,
Downtime.start_time < effective_window_end,
func.coalesce(Downtime.end_time, effective_window_end) > window_start,
)
).scalars()
downtime_sec = sum(
overlap_seconds(window_start, effective_window_end, row.start_time, min(row.end_time, effective_window_end) if row.end_time else effective_window_end)
for row in downtime_rows
)
scrap_qty = session.execute(
select(func.coalesce(func.sum(Scrap.quantity), 0)).where(
Scrap.machine_id == machine_id,
Scrap.timestamp >= window_start,
Scrap.timestamp <= effective_window_end,
)
).scalar_one()
if planned_time_sec == 0 and (total_cycles > 0 or downtime_sec > 0):
planned_time_sec = (effective_window_end - window_start).total_seconds()
totals = calculate_oee_metrics(
planned_time_sec=planned_time_sec,
downtime_sec=downtime_sec,
total_cycles=total_cycles,
performance_time_sec=performance_time_sec,
theoretical_cycle_time_sec=theoretical_cycle_time_sec,
total_produced_qty=total_produced_qty,
scrap_qty=int(scrap_qty),
)
return {
"machine_id": machine_id,
"from": window_start.isoformat(),
"to": effective_window_end.isoformat(),
**totals.as_dict(),
}
def compute_daily_oee(session: Session, target_date: date, machine_ids: list[int]) -> dict:
window_start = datetime.combine(target_date, time.min, tzinfo=UTC)
window_end = window_start + timedelta(days=1)
machine_metrics = [compute_machine_oee(session, machine_id, window_start, window_end) for machine_id in machine_ids]
overall = calculate_oee_metrics(
planned_time_sec=sum(item["planned_time_sec"] for item in machine_metrics),
downtime_sec=sum(item["downtime_sec"] for item in machine_metrics),
total_cycles=sum(item["total_cycles"] for item in machine_metrics),
performance_time_sec=sum(item["theoretical_cycle_time_sec"] * item["total_cycles"] for item in machine_metrics),
theoretical_cycle_time_sec=0,
total_produced_qty=sum(item["total_produced_qty"] for item in machine_metrics),
scrap_qty=sum(item["scrap_qty"] for item in machine_metrics),
)
return {
"date": target_date.isoformat(),
"machines": machine_metrics,
"overall": overall.as_dict(),
}

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from fastapi import WebSocket
class RealtimeHub:
def __init__(self) -> None:
self._connections: set[WebSocket] = set()
self._loop: asyncio.AbstractEventLoop | None = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
async def connect(self, websocket: WebSocket) -> None:
await websocket.accept()
self._connections.add(websocket)
def disconnect(self, websocket: WebSocket) -> None:
self._connections.discard(websocket)
async def broadcast(self, payload: dict) -> None:
stale: list[WebSocket] = []
for websocket in self._connections:
try:
await websocket.send_json(payload)
except Exception:
stale.append(websocket)
for websocket in stale:
self._connections.discard(websocket)
def notify(self, payload: dict) -> None:
if self._loop is None:
return
asyncio.run_coroutine_threadsafe(self.broadcast(payload), self._loop)
hub = RealtimeHub()