486e1288bf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
445 lines
17 KiB
Python
445 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),
|
|
"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,
|
|
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.
|
|
"""
|
|
_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.
|
|
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
|