Files
TieMeasureFlow/src/backend/models/api/measurement.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

107 lines
3.8 KiB
Python

"""Pydantic schemas for Measurement operations."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class MeasurementCreate(BaseModel):
"""Schema for creating a single measurement."""
subtask_id: int
version_id: int
# Finiteness and the DECIMAL(12,6) scale bound are enforced in
# measurement_service.save_measurement, not with a Field constraint here:
# a NaN/Infinity that fails a pydantic Field constraint gets echoed back
# in the validation error's "input", and FastAPI's default error response
# then crashes with a 500 trying to JSON-encode that same NaN/Infinity
# (json.dumps(allow_nan=False) in Starlette's JSONResponse). A plain
# HTTPException raised after parsing sidesteps that.
value: float
lot_number: Optional[str] = Field(None, max_length=100)
serial_number: Optional[str] = Field(None, max_length=100)
# "camera" is deliberately absent from this pattern, even though the ORM
# enum and the database allow it. Do not add it here: a camera
# measurement must be produced by vision_service.execute_task, never by a
# client posting JSON straight to this endpoint - there is no image, no
# graph and no engine_version behind a value that arrived this way.
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
input_duration_ms: Optional[int] = Field(None, ge=0)
# The production this belongs to, when one is open at the station.
production_run_id: Optional[int] = Field(None, gt=0)
class MeasurementBatchCreate(BaseModel):
"""Schema for creating multiple measurements at once."""
measurements: list[MeasurementCreate]
class MeasurementResponse(BaseModel):
"""Schema for measurement response."""
model_config = ConfigDict(from_attributes=True)
id: int
subtask_id: int
version_id: int
measured_by: int
value: float
pass_fail: str
deviation: Optional[float] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
input_method: str
input_duration_ms: Optional[int] = None
production_run_id: Optional[int] = None
# Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None
authorised_at: Optional[datetime] = None
measured_at: datetime
synced_to_csv: bool
class MeasurementListResponse(BaseModel):
"""Schema for paginated measurement list."""
items: list[MeasurementResponse]
total: int
page: int
per_page: int
pages: int
class MeasurementAuthorisation(BaseModel):
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
class TaskProgressResponse(BaseModel):
"""How far one measurement task has got: quotes taken out of quotes expected."""
task_id: int
quotes: int
measured: int
# empty (declared as measurement, no quotes yet) | none | partial | complete
state: str
class TaskProgressListResponse(BaseModel):
"""Progress of every measurement task of a version."""
tasks: list[TaskProgressResponse]
class MeasurementQuery(BaseModel):
"""Schema for measurement query filters."""
recipe_id: Optional[int] = None
version_id: Optional[int] = None
subtask_id: Optional[int] = None
measured_by: Optional[int] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
page: int = Field(1, ge=1)
per_page: int = Field(50, ge=1, le=500)