Initial Plast Track MVP
This commit is contained in:
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(),
|
||||
}
|
||||
Reference in New Issue
Block a user