Files
TieMeasureFlow/src/backend/services/measurement_service.py
T

453 lines
17 KiB
Python

"""Measurement service - pass/fail calculation, data storage."""
import math
from datetime import datetime
from src.backend.utils.tempo import utcnow
from decimal import Decimal
from typing import TYPE_CHECKING
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import (
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
)
from src.backend.models.orm.user import User
if TYPE_CHECKING:
from src.backend.models.orm.production import ProductionRun
# What the Measurement.value column (DECIMAL(12,6)) can actually hold.
_MAX_ABS_VALUE = 1_000_000
def _validate_measured_value(value: float) -> None:
"""Refuse a value that would corrupt the verdict or the INSERT.
NaN makes every tolerance comparison false, so a bad reading silently
comes out as "pass"; NaN and Infinity then both fail the DECIMAL(12,6)
column at insert time with an IntegrityError the operator never asked
for. Raised as a plain HTTPException with a string detail rather than as
a pydantic Field constraint on the request model: a Field rejecting NaN
(via allow_inf_nan or a gt/lt bound) makes pydantic echo that same NaN
back in the validation error's "input", and FastAPI's default error
response then crashes with a 500 trying to JSON-encode it
(Starlette's JSONResponse calls json.dumps(..., allow_nan=False)).
"""
if not math.isfinite(value):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Measured value must be a finite number (no NaN/Infinity)",
)
if abs(value) >= _MAX_ABS_VALUE:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Measured value out of range: |value| must be < {_MAX_ABS_VALUE}",
)
def calculate_pass_fail(
value: float, subtask: RecipeSubtask
) -> tuple[str, float | None]:
"""Calculate pass/fail status and deviation based on tolerances.
Returns (status, deviation) where status is 'pass', 'warning', or 'fail'.
Logic:
- If value is outside UTL/LTL -> 'fail'
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
- Otherwise -> 'pass'
The value is rounded to 6 decimals first - the precision the DECIMAL(12,6)
column keeps - so the verdict is computed on the same number that ends up
stored, and a value like 10.5000004 does not fail against a 10.5 limit.
"""
value = round(value, 6)
deviation = None
if subtask.nominal is not None:
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
# Check fail (outside tolerance limits)
if subtask.utl is not None and value > float(subtask.utl):
return "fail", deviation
if subtask.ltl is not None and value < float(subtask.ltl):
return "fail", deviation
# Check warning (outside warning limits)
if subtask.uwl is not None and value > float(subtask.uwl):
return "warning", deviation
if subtask.lwl is not None and value < float(subtask.lwl):
return "warning", deviation
return "pass", deviation
async def recipe_of_version(db: AsyncSession, version_id: int) -> Recipe | None:
"""The recipe a version belongs to - where the rules of measurement live."""
result = await db.execute(
select(Recipe)
.join(RecipeVersion, RecipeVersion.recipe_id == Recipe.id)
.where(RecipeVersion.id == version_id)
)
return result.scalar_one_or_none()
def _enforce_recipe_rules(
recipe: Recipe | None,
lot_number: str | None,
serial_number: str | None,
input_method: str,
) -> None:
"""Refuse a measurement the recipe does not allow.
Checked here rather than only on the screen because a rule that lives in the
frontend is a suggestion: the keypad can be hidden and the same request still
sent. This is the one place every measurement passes through.
"""
if recipe is None:
return
if recipe.requires_lot and not (lot_number or "").strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="This recipe requires a lot number",
)
if recipe.requires_serial and not (serial_number or "").strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="This recipe requires a serial number",
)
if input_method == "manual" and not recipe.allow_manual_input:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="This recipe does not allow values typed by hand: use the caliper",
)
def _scope(query, version_id: int, measured_by: int, production_run_id: int | None):
"""Narrow a measurement query to the run in progress, or to this operator's work.
Inside a production the run is the boundary. Outside one - a recipe executed on
its own - the boundary is the version and the person doing it, so two operators
on the same recipe do not block each other.
"""
if production_run_id is not None:
return query.where(Measurement.production_run_id == production_run_id)
return query.where(
Measurement.version_id == version_id,
Measurement.measured_by == measured_by,
Measurement.production_run_id.is_(None),
)
async def pending_authorisation(
db: AsyncSession,
version_id: int,
measured_by: int,
production_run_id: int | None = None,
) -> Measurement | None:
"""The out-of-tolerance measurement that is holding everything up, if any.
Only the *latest* reading of each quote counts. Measuring the same quote again
is allowed - the caliper slips, the piece is reseated - and a second reading
within tolerance releases the block. What is not allowed is moving on to the
next quote while the one in hand is out of tolerance and nobody has said so.
How many attempts that permits is point 4's business, not this one's. Every
reading stays in the record either way.
"""
latest_per_subtask = _scope(
select(func.max(Measurement.id)),
version_id, measured_by, production_run_id,
).group_by(Measurement.subtask_id)
result = await db.execute(
select(Measurement)
.where(
Measurement.id.in_(latest_per_subtask),
Measurement.pass_fail == "fail",
Measurement.supervisor_id.is_(None),
)
.order_by(Measurement.id)
.limit(1)
)
return result.scalar_one_or_none()
async def task_progress(
db: AsyncSession,
version_id: int,
measured_by: int,
production_run_id: int | None = None,
) -> list[dict]:
"""How far each measurement task of a version has got.
A task left half done is the thing the operator cannot see today: they walk
away from it, come back to the list, and it looks exactly like one never
started. Counting quotes against measurements is what tells them apart.
Documental tasks are not in here. A note cannot be half read, and marking one
incomplete for ever would be noise on the only screen that has to stay legible.
Which tasks count is read from the declared type, not from whether quotes happen
to have been entered yet - the same rule point 2 settled everywhere else.
"""
rows = (await db.execute(
select(RecipeTask.id, RecipeSubtask.id)
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id, isouter=True)
.where(
RecipeTask.version_id == version_id,
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
)
)).all()
if not rows:
return []
by_task: dict[int, set[int]] = {}
for task_id, subtask_id in rows:
quotes = by_task.setdefault(task_id, set())
if subtask_id is not None:
quotes.add(subtask_id)
measured = set((await db.execute(
_scope(
select(Measurement.subtask_id).distinct(),
version_id, measured_by, production_run_id,
)
)).scalars().all())
progress = []
for task_id, subtask_ids in by_task.items():
total = len(subtask_ids)
done = len(subtask_ids & measured)
if total == 0:
# Declared as a measurement but with no quotes on it yet: nothing to
# take, so nothing to report as missing either.
state = "empty"
elif done == total:
state = "complete"
elif done:
state = "partial"
else:
state = "none"
progress.append({
"task_id": task_id,
"quotes": total,
"measured": done,
"state": state,
})
progress.sort(key=lambda row: row["task_id"])
return progress
async def current_cycle_measurements(
db: AsyncSession, run_id: int, task_id: int,
) -> list[dict]:
"""The quotes of a task already measured in the cycle that is under way.
A reload, a timer redirect or an auto-logout throws away what the page held in
memory; the operator would measure the same quotes again and the duplicates
would reach the SPC statistics. The cycle needs no column of its own: it starts
at the latest event of the run that opens one for this task - the run start, a
cycle_completed, or a remeasure of this task.
If a task_measured of this task follows that boundary the task is already
closed in this cycle and there is nothing to restore. Where a quote was measured
more than once, the last reading stands.
"""
boundary = (await db.execute(
select(ProductionEvent.id, ProductionEvent.created_at)
.where(
ProductionEvent.run_id == run_id,
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
| (
(ProductionEvent.event_type == "remeasure")
& (ProductionEvent.task_id == task_id)
),
)
.order_by(ProductionEvent.id.desc())
.limit(1)
)).first()
event_filter = [ProductionEvent.run_id == run_id]
measurement_filter = [Measurement.production_run_id == run_id]
if boundary is not None:
boundary_id, boundary_at = boundary
event_filter.append(ProductionEvent.id > boundary_id)
measurement_filter.append(Measurement.measured_at > boundary_at)
closed = (await db.execute(
select(ProductionEvent.id).where(
*event_filter,
ProductionEvent.event_type == "task_measured",
ProductionEvent.task_id == task_id,
).limit(1)
)).first()
if closed is not None:
return []
rows = (await db.execute(
select(Measurement)
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
.order_by(Measurement.measured_at, Measurement.id)
)).scalars().all()
latest: dict[int, Measurement] = {}
for m in rows:
latest[m.subtask_id] = m
return [
{
"subtask_id": m.subtask_id,
"value": float(m.value) if m.value is not None else None, # #118: NaN
"pass_fail": m.pass_fail,
"measured_at": m.measured_at,
}
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
]
async def validate_run_for_version(
db: AsyncSession, run_id: int, version_id: int,
) -> "ProductionRun":
"""The run a measurement is being filed against, checked for coherence.
Three things have to hold before a value goes in against a production: the
run has to exist, it has to belong to the same recipe as the version being
measured (a run started for another product must not collect another
product's history), and it has to be in a state that accepts measurements
(not closed, not paused). Shared with vision_service, which files camera
measurements against a run the same way a manual reading does.
"""
# Local import: production_service imports this module at module scope,
# so importing it back here at module scope would be circular.
from src.backend.services import production_service
run = await production_service.get_run(db, run_id) # 404 if missing
recipe = await recipe_of_version(db, version_id)
if recipe is None or run.recipe_id != recipe.id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Production run belongs to a different recipe than this version",
)
production_service._require_measurable(run) # 409 if closed or paused
return run
async def authorise_measurement(
db: AsyncSession, measurement: Measurement, supervisor: User,
) -> Measurement:
"""Let an out-of-tolerance value stand, on the supervisor's name."""
measurement.supervisor_id = supervisor.id
measurement.authorised_at = utcnow()
await db.flush()
await db.refresh(measurement)
return measurement
async def save_measurement(
db: AsyncSession,
subtask_id: int,
version_id: int,
measured_by: int,
value: float | None,
lot_number: str | None = None,
serial_number: str | None = None,
input_method: str = "manual",
input_duration_ms: int | None = None,
production_run_id: int | None = None,
vision_result_id: int | None = None,
enforce_tolerance_gate: bool = True,
) -> Measurement:
"""Save a single measurement with auto-calculated pass/fail.
``enforce_tolerance_gate=False`` skips only the pending-authorisation check
below - everything else (verdict, recipe rules, deviation) still applies.
This is for a caller that has already evaluated the gate itself for a whole
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
the gate models "moving on" between measurements taken one after another,
which does not exist inside a single camera acquisition.
#118: ``value=None`` is a quote the vision graph did not produce (shown as
NaN): saved as a ``fail`` with no deviation. Only the vision path passes it.
"""
if value is not None: # #118: una quota non misurata non ha un valore da validare
_validate_measured_value(value)
# Get subtask for tolerance values
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
)
subtask = result.scalar_one_or_none()
if subtask is None:
raise ValueError(f"Subtask {subtask_id} not found")
# The quote has to belong to a task of the declared version - otherwise the
# tolerances just read off `subtask` are not the ones this version's operator
# was shown, and pass/fail is computed against the wrong recipe.
task_row = await db.execute(
select(RecipeTask.version_id).where(RecipeTask.id == subtask.task_id)
)
subtask_version_id = task_row.scalar_one_or_none()
if subtask_version_id != version_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Subtask does not belong to the given version",
)
if production_run_id is not None:
await validate_run_for_version(db, production_run_id, version_id)
recipe = await recipe_of_version(db, version_id)
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
# No moving on while a quote is out of tolerance and unauthorised. Measuring
# that same quote again is the way out that does not need the capoturno; the
# next quote is not.
if enforce_tolerance_gate:
blocking = await pending_authorisation(
db, version_id, measured_by, production_run_id,
)
if blocking is not None and blocking.subtask_id != subtask_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A measurement is out of tolerance and awaiting the supervisor: "
"authorise it or measure that quote again before going on"
),
)
# Rounded once, here, so the stored value is exactly the one the verdict
# above was computed on - calculate_pass_fail rounds internally too, but
# matching it here keeps the persisted row from disagreeing with itself.
if value is None:
# #118: quota non prodotta dal grafo -> NaN: fail, nessuna deviazione
pass_fail, deviation = "fail", None
else:
value = round(value, 6)
pass_fail, deviation = calculate_pass_fail(value, subtask)
measurement = Measurement(
subtask_id=subtask_id,
version_id=version_id,
measured_by=measured_by,
value=value,
pass_fail=pass_fail,
deviation=deviation,
lot_number=lot_number,
serial_number=serial_number,
input_method=input_method,
input_duration_ms=input_duration_ms,
production_run_id=production_run_id,
vision_result_id=vision_result_id,
)
db.add(measurement)
await db.flush()
await db.refresh(measurement)
return measurement