Files
TieMeasureFlow/src/backend/services/monitor_service.py
T
2026-10-07 12:25:16 +02:00

134 lines
5.3 KiB
Python

"""Live monitor of the open production runs (#57).
One query per table, whatever the number of runs: measurements and last events are
ranked per run with a window function and cut in the database, not in a loop.
"""
from collections import defaultdict
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.api.monitor import MonitorEvent, MonitorRun, MonitorSubtask
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.task import RecipeSubtask
from src.backend.models.orm.user import User
from src.backend.services import production_service
# Recent measurements per run that feed counts and means (#57: last N).
MONITOR_WINDOW = 20
def _in_tolerance(mean: float, ltl, utl) -> bool:
# A missing limit does not constrain.
if ltl is not None and mean < float(ltl):
return False
if utl is not None and mean > float(utl):
return False
return True
async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
runs = (await db.execute(
select(ProductionRun)
.where(ProductionRun.status.in_(("running", "paused")))
.order_by(ProductionRun.started_at)
)).scalars().all()
if not runs:
return []
run_ids = [r.id for r in runs]
# Operators: one query.
users = {
u.id: u for u in (await db.execute(
select(User).where(User.id.in_({r.operator_id for r in runs}))
)).scalars().all()
}
# Last event per run: rank desc inside the run, keep rank 1.
ev_rank = func.row_number().over(
partition_by=ProductionEvent.run_id,
order_by=(ProductionEvent.created_at.desc(), ProductionEvent.id.desc()),
).label("rn")
ev_sub = select(
ProductionEvent.run_id.label("run_id"),
ProductionEvent.event_type.label("event_type"),
ProductionEvent.created_at.label("created_at"),
ProductionEvent.note.label("note"),
ev_rank,
).where(ProductionEvent.run_id.in_(run_ids)).subquery()
ev_rows = (await db.execute(
select(ev_sub.c.run_id, ev_sub.c.event_type, ev_sub.c.created_at, ev_sub.c.note)
.where(ev_sub.c.rn == 1)
)).all()
last_event = {r.run_id: r for r in ev_rows}
# Last N measurements per run, joined to their subtask for the limits.
m_rank = func.row_number().over(
partition_by=Measurement.production_run_id,
order_by=(Measurement.measured_at.desc(), Measurement.id.desc()),
).label("rn")
m_sub = select(
Measurement.production_run_id.label("run_id"),
Measurement.subtask_id.label("subtask_id"),
Measurement.value.label("value"),
Measurement.pass_fail.label("pass_fail"),
m_rank,
).where(Measurement.production_run_id.in_(run_ids)).subquery()
m_rows = (await db.execute(
select(
m_sub.c.run_id, m_sub.c.subtask_id, m_sub.c.value, m_sub.c.pass_fail,
RecipeSubtask.description, RecipeSubtask.unit,
RecipeSubtask.ltl, RecipeSubtask.utl,
)
.join(RecipeSubtask, RecipeSubtask.id == m_sub.c.subtask_id)
.where(m_sub.c.rn <= MONITOR_WINDOW)
)).all()
by_run = defaultdict(list)
for row in m_rows:
by_run[row.run_id].append(row)
result = []
for run in runs:
rows = by_run.get(run.id, [])
per_sub: dict[int, list] = defaultdict(list)
for row in rows:
per_sub[row.subtask_id].append(row)
subtasks = []
for sid, group in sorted(per_sub.items()):
mean = sum(float(g.value) for g in group) / len(group)
first = group[0]
subtasks.append(MonitorSubtask(
subtask_id=sid, description=first.description, unit=first.unit,
count=len(group), mean=round(mean, 6),
ltl=float(first.ltl) if first.ltl is not None else None,
utl=float(first.utl) if first.utl is not None else None,
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
))
timer = production_service.describe(run)
ev = last_event.get(run.id)
operator = users.get(run.operator_id)
result.append(MonitorRun(
id=run.id,
station_code=run.station.code, station_name=run.station.name,
recipe_code=run.recipe.code, recipe_name=run.recipe.name,
version_number=run.version.version_number,
operator=(operator.display_name or operator.username) if operator else "",
status=run.status,
started_at=run.started_at,
duration_seconds=max(0, int((timer["server_time"] - run.started_at).total_seconds())),
cycle_count=run.cycle_count,
seconds_to_next_measurement=timer["seconds_to_next_measurement"],
overdue=timer["overdue"],
last_event=MonitorEvent(
event_type=ev.event_type, created_at=ev.created_at, note=ev.note,
) if ev else None,
measured=len(rows),
pass_count=sum(1 for g in rows if g.pass_fail == "pass"),
warning_count=sum(1 for g in rows if g.pass_fail == "warning"),
fail_count=sum(1 for g in rows if g.pass_fail == "fail"),
subtasks=subtasks,
server_time=timer["server_time"],
))
return result