Files
TieMeasureFlow/src/backend/services/measurement_service.py
T
Adriano 7b015078e9 fix(measurements): value NaN/Inf/scala e coerenza quota/versione/run (#5, #20)
#20 - models/api/measurement.py: value accettava NaN/Infinity/fuori scala,
ogni confronto tornava falso ("pass") e poi l'INSERT falliva. Il controllo
sposta in measurement_service._validate_measured_value (math.isfinite +
|value|<1e6): un Field pydantic (allow_inf_nan/gt/lt) rifiuta la stessa cosa
ma FastAPI, costruendo l'errore 422, riecheggia il NaN nel campo "input" e
Starlette lo nega con allow_nan=False, producendo un 500 al posto del 422 -
verificato con una chiamata reale, non solo a livello di modello.
calculate_pass_fail e save_measurement arrotondano value a 6 decimali (stessa
scala di DECIMAL(12,6)) prima di calcolare e salvare, cosi' il verdetto e il
valore persistito non divergono mai.

#5 - measurement_service.save_measurement: prima dell'insert verifica che la
quota appartenga a un task della version_id dichiarata (422 altrimenti), e se
production_run_id e' dato usa la nuova validate_run_for_version(db, run_id,
version_id): il run deve esistere (404), essere della stessa ricetta della
versione (422), essere misurabile - riusa production_service._require_measurable
(409 se chiuso o in pausa). Import locale per evitare un ciclo con
production_service, che gia' importa measurement_service.

Test: gia' presenti in test_measurements.py (TestMeasurementValueBounds,
TestSaveMeasurementCoherence) - finiti nel commit 0dce229 di un altro agente
che lavorava in parallelo sullo stesso file (export CSV, #22); confermato con
`git show 0dce229 -- test_measurements.py`, contenuto intatto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:47:58 +02:00

376 lines
14 KiB
Python

"""Measurement service - pass/fail calculation, data storage."""
import math
from datetime import datetime
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.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 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 = datetime.now()
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