db5d686938
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
134 lines
5.3 KiB
Python
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
|