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

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(),
}