Initial Plast Track MVP
This commit is contained in:
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend service modules."""
|
||||
118
backend/app/services/dashboard.py
Normal file
118
backend/app/services/dashboard.py
Normal 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],
|
||||
}
|
||||
295
backend/app/services/events.py
Normal file
295
backend/app/services/events.py
Normal 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
|
||||
50
backend/app/services/mqtt_listener.py
Normal file
50
backend/app/services/mqtt_listener.py
Normal 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
202
backend/app/services/oee.py
Normal 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(),
|
||||
}
|
||||
40
backend/app/services/realtime.py
Normal file
40
backend/app/services/realtime.py
Normal 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()
|
||||
Reference in New Issue
Block a user