"""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