Visione: quota non prodotta salvata come NaN (value NULL, fail) invece del 422; migrazione 022

Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 21:21:54 +01:00
parent 4485803122
commit 06f308be6f
15 changed files with 317 additions and 71 deletions
+2 -1
View File
@@ -357,7 +357,8 @@ async def export_measurements_csv(
# Rows
for m in measurements:
# Format decimals with configured separator
value_str = str(m.value).replace(".", decimal_separator)
# #118: quota non misurata dalla visione -> NaN
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
deviation_str = (
str(m.deviation).replace(".", decimal_separator)
if m.deviation is not None
+6 -2
View File
@@ -146,7 +146,8 @@ async def get_capability(
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
values = [float(m.value) for m in measurements]
# #118: le quote NaN (value None) non entrano nei calcoli
values = [float(m.value) for m in measurements if m.value is not None]
tol = await _get_subtask_tolerances(db, subtask_id)
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
@@ -171,6 +172,8 @@ async def get_control_chart(
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
# #118: le quote NaN (value None) si saltano, valori e istanti restano allineati
measurements = [m for m in measurements if m.value is not None]
values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id)
@@ -201,7 +204,8 @@ async def get_histogram(
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
values = [float(m.value) for m in measurements]
# #118: le quote NaN (value None) non entrano nell'istogramma
values = [float(m.value) for m in measurements if m.value is not None]
return compute_histogram(values, n_bins)
+4 -1
View File
@@ -162,7 +162,10 @@ async def execute(
engine_version=result.engine_version,
measurements=[
VisionMeasurementResult(
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
subtask_id=m.subtask_id,
# #118: None = quota non prodotta, il frontend la mostra NaN
value=float(m.value) if m.value is not None else None,
pass_fail=m.pass_fail,
)
for m in saved
],
@@ -0,0 +1,39 @@
"""measurements.value nullable (#118)
measurements.value: NULL = quota che il grafo di visione non ha prodotto
(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima
execute rifiutava con 422 l'intera acquisizione. measurements.deviation era
gia' nullable.
Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno
cancellate o valorizzate prima).
Revision ID: 022_measurement_value_nullable
Revises: 021_task_acquisition
Create Date: 2026-10-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '022_measurement_value_nullable'
down_revision: Union[str, None] = '021_task_acquisition'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'value', existing_type=sa.DECIMAL(12, 6), nullable=True,
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.alter_column(
'value', existing_type=sa.DECIMAL(12, 6), nullable=False,
)
+2 -2
View File
@@ -43,7 +43,7 @@ class MeasurementResponse(BaseModel):
subtask_id: int
version_id: int
measured_by: int
value: float
value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
pass_fail: str
deviation: Optional[float] = None
lot_number: Optional[str] = None
@@ -108,7 +108,7 @@ class CycleMeasurementResponse(BaseModel):
"""One quote already measured in the cycle under way."""
subtask_id: int
value: float
value: Optional[float] = None # #118: null = NaN
pass_fail: str
measured_at: UtcDateTime
+1 -1
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
class VisionMeasurementResult(BaseModel):
"""One quote's outcome, as much of it as the client needs to show."""
subtask_id: int
value: float
value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail"
pass_fail: str
+2 -1
View File
@@ -26,7 +26,8 @@ class Measurement(Base):
)
# Measurement data
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False)
# #118: NULL = quota che il grafo di visione non ha prodotto (mostrata NaN, sempre fail)
value: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
pass_fail: Mapped[str] = mapped_column(
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
)
+13 -5
View File
@@ -302,7 +302,7 @@ async def current_cycle_measurements(
return [
{
"subtask_id": m.subtask_id,
"value": float(m.value),
"value": float(m.value) if m.value is not None else None, # #118: NaN
"pass_fail": m.pass_fail,
"measured_at": m.measured_at,
}
@@ -355,7 +355,7 @@ async def save_measurement(
subtask_id: int,
version_id: int,
measured_by: int,
value: float,
value: float | None,
lot_number: str | None = None,
serial_number: str | None = None,
input_method: str = "manual",
@@ -372,8 +372,12 @@ async def save_measurement(
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.
"""
_validate_measured_value(value)
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(
@@ -421,8 +425,12 @@ async def save_measurement(
# 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)
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,
+6 -1
View File
@@ -96,7 +96,12 @@ async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
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)
# #118: le quote NaN (value None) non entrano nella media; senza
# nessun valore numerico la quota non ha una media da mostrare
numeric = [float(g.value) for g in group if g.value is not None]
if not numeric:
continue
mean = sum(numeric) / len(numeric)
first = group[0]
subtasks.append(MonitorSubtask(
subtask_id=sid, description=first.description, unit=first.unit,
@@ -159,7 +159,7 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
subtask.marker_number if subtask else "",
neutralise_csv_cell(subtask.description) if subtask else "",
number(subtask.nominal) if subtask else "",
number(measurement.value),
number(measurement.value) if measurement.value is not None else "NaN", # #118
number(measurement.deviation),
measurement.pass_fail,
number(subtask.ltl) if subtask else "",
+5 -3
View File
@@ -318,8 +318,10 @@ async def generate_spc_report(
# 2. Compute SPC data
pass_fail_values = [m.pass_fail for m in measurements]
values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements]
# #118: le quote NaN (value None) contano nell'esito ma non nei calcoli
numeric = [m for m in measurements if m.value is not None]
values = [float(m.value) for m in numeric]
timestamps = [m.measured_at for m in numeric]
summary = compute_summary(pass_fail_values)
@@ -426,7 +428,7 @@ async def generate_measurement_report(
rows.append({
"num": i,
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}",
"value": f"{float(m.value):.4f}",
"value": f"{float(m.value):.4f}" if m.value is not None else "NaN", # #118
"unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail,
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
+19 -21
View File
@@ -693,23 +693,13 @@ async def execute_task(
)
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
# than a piece not measured at all. A quote counts as produced only if its
# output is present *and* a finite, non-bool number: the in-process runner
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
# is reached over HTTP - a different implementation of it, or a malformed
# response, could still hand back None or a non-finite float for a mapped
# key instead of omitting it outright.
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
if missing:
failed = ", ".join(f["tool_name"] for f in report["failures"])
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"the graph did not produce {', '.join(missing)}"
+ (f" (tools that failed: {failed})" if failed else "")
),
)
# #118: una quota che il grafo non produce non blocca piu' l'esecuzione
# (prima 422 "the graph did not produce"): viene salvata con value NULL,
# mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato.
# Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero
# finito non-bool: il worker risponde via HTTP, un'altra implementazione o
# una risposta malformata potrebbe dare None o un non finito per una chiave
# mappata invece di ometterla.
overlay = report.get("overlay") or []
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
@@ -737,7 +727,11 @@ async def execute_task(
subtask_id=quote.id,
version_id=version_id,
measured_by=measured_by,
value=float(outputs[quote.vision_output]),
# #118: None = quota non prodotta (NaN), salvata come fail
value=(
float(outputs[quote.vision_output])
if _is_measurable(outputs.get(quote.vision_output)) else None
),
lot_number=lot_number,
serial_number=serial_number,
input_method="camera",
@@ -762,8 +756,9 @@ async def check_task(
VisionResult, niente file, niente misure. L'esito per quota usa
calculate_pass_fail sulle quote con vision_output, nell'ordine di
marker_number come execute. Le quote che il grafo non produce (pezzo non
ancora in posizione) restano fuori da `measurements` e il perche' sta in
`failures`: un'anteprima non deve dare 422 a ogni frame.
ancora in posizione) entrano in `measurements` con `value` None (NaN) e
`pass_fail` "fail" (#118, come execute); il perche' sta in `failures`:
un'anteprima non deve dare 422 a ogni frame.
"""
if task.task_type not in VISION_TASK_TYPES:
raise HTTPException(
@@ -809,6 +804,7 @@ async def check_task(
**worker_kwargs,
)
outputs = report["outputs"]
# #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail
measurements = [
{
"subtask_id": q.id,
@@ -816,8 +812,10 @@ async def check_task(
"pass_fail": measurement_service.calculate_pass_fail(
float(outputs[q.vision_output]), q,
)[0],
} if _is_measurable(outputs.get(q.vision_output)) else {
"subtask_id": q.id, "value": None, "pass_fail": "fail",
}
for q in quotes if _is_measurable(outputs.get(q.vision_output))
for q in quotes
]
return {
"outcome": report.get("outcome"),
+19
View File
@@ -360,3 +360,22 @@ class TestTaskAcquisitionMigration:
assert "camera_url" in cols("stations") # la 020 resta
command.upgrade(cfg, "head") # e di nuovo su
assert "acquisition_json" in cols("recipe_tasks")
class TestMeasurementValueNullableMigration:
"""022_measurement_value_nullable: measurements.value accetta NULL, su e giu (#118)."""
def test_022_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def value_nullable():
cols = _inspect(db_path).get_columns("measurements")
return {c["name"]: c["nullable"] for c in cols}["value"]
command.upgrade(cfg, "head")
assert value_nullable() is True
command.downgrade(cfg, "021_task_acquisition")
assert value_nullable() is False
command.upgrade(cfg, "head") # e di nuovo su
assert value_nullable() is True
+114
View File
@@ -0,0 +1,114 @@
"""#118: una quota non misurata dalla visione e' una misura con value NULL (NaN), sempre fail.
Il valore NULL non deve far cadere statistiche, report, CSV e liste, e non entra
nei calcoli numerici (medie, indici, istogramma); conta solo nell'esito.
"""
import csv
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.user import User
from src.backend.services import measurement_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_statistics import _seed_measurements
async def _recipe_with_nan(client, db_session, tec_user):
"""Una ricetta con tre misure numeriche e una NaN (value NULL, fail)."""
recipe = await create_test_recipe(db_session, tec_user.id)
subtask_id, version_id = await _seed_measurements(
client, tec_user, recipe.id, [10.0, 10.1, 9.9],
)
nan = await measurement_service.save_measurement(
db_session, subtask_id=subtask_id, version_id=version_id,
measured_by=tec_user.id, value=None, input_method="camera",
enforce_tolerance_gate=False,
)
await db_session.commit()
return recipe, subtask_id, version_id, nan
async def test_save_measurement_none_is_fail_without_deviation(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
assert nan.value is None
assert nan.pass_fail == "fail"
assert nan.deviation is None
async def test_manual_create_still_requires_a_value(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
"""Solo il percorso visione puo' produrre NaN: la creazione manuale resta float."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _seed_measurements(
client, measurement_tec_user, recipe.id, [],
)
resp = await client.post(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
json={"subtask_id": subtask_id, "version_id": version_id, "value": None},
)
assert resp.status_code == 422
async def test_statistics_skip_the_nan_measurement(
client: AsyncClient, db_session: AsyncSession,
measurement_tec_user: User, metrologist_user: User,
):
recipe, subtask_id, _, _ = await _recipe_with_nan(
client, db_session, measurement_tec_user,
)
params = {"recipe_id": recipe.id, "subtask_id": subtask_id}
headers = auth_headers(metrologist_user)
cap = await client.get("/api/statistics/capability", headers=headers, params=params)
assert cap.status_code == 200
assert cap.json()["n"] == 3
assert abs(cap.json()["mean"] - 10.0) < 1e-9
chart = await client.get("/api/statistics/control-chart", headers=headers, params=params)
assert chart.status_code == 200
assert len(chart.json()["values"]) == 3
hist = await client.get("/api/statistics/histogram", headers=headers, params=params)
assert hist.status_code == 200
summary = await client.get(
"/api/statistics/summary", headers=headers, params={"recipe_id": recipe.id},
)
assert summary.status_code == 200
async def test_measurement_list_returns_null_value(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, version_id, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
resp = await client.get(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
params={"version_id": version_id, "per_page": 50},
)
assert resp.status_code == 200
rows = {m["id"]: m for m in resp.json()["items"]}
assert rows[nan.id]["value"] is None
assert rows[nan.id]["pass_fail"] == "fail"
assert rows[nan.id]["deviation"] is None
async def test_csv_export_writes_nan(
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
):
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
resp = await client.get(
"/api/measurements/export/csv", headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200
rows = list(csv.reader(resp.text.splitlines()))
header = rows[0]
by_id = {r[header.index("id")]: r for r in rows[1:]}
row = by_id[str(nan.id)]
assert row[header.index("value")] == "NaN"
assert row[header.index("pass_fail")] == "fail"
assert row[header.index("deviation")] == ""
+84 -32
View File
@@ -134,72 +134,124 @@ async def test_the_execution_records_which_engine_produced_it(
assert result.graph_snapshot == task.vision_json
# #118: una quota che il grafo non produce non e' piu' un 422: e' NaN (value
# NULL), `fail`, salvata con le altre.
async def _assert_nan_saved(client, db_session, user, task, first, second):
response = await client.post(
"/api/vision/execute",
headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200
body = response.json()
assert [m["subtask_id"] for m in body["measurements"]] == [first.id, second.id]
assert body["measurements"][0]["value"] is None
assert body["measurements"][0]["pass_fail"] == "fail"
assert body["measurements"][1]["value"] == GOOD
rows = (await db_session.execute(
select(Measurement).order_by(Measurement.subtask_id)
)).scalars().all()
assert len(rows) == 2
assert rows[0].value is None
assert rows[0].pass_fail == "fail"
assert rows[0].deviation is None
assert rows[0].vision_result_id is not None
assert rows[1].value is not None and rows[1].pass_fail == "pass"
@pytest.mark.asyncio
async def test_a_missing_output_names_itself(
async def test_a_missing_output_is_saved_as_nan(
client, db_session, measurement_tec_user, worker_says,
):
"""No half measurement: if an output is missing, the error names it."""
"""#118: output assente -> 200, misura NULL/fail, le altre quote salvate."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.radius": 5.0})
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.radius": GOOD}, failures=[{"tool_name": "arco_tr"}])
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
@pytest.mark.asyncio
async def test_all_outputs_missing_still_saves_every_quote(
client, db_session, measurement_tec_user, worker_says,
):
"""#118: anche senza nessuna uscita, una misura NaN per quota."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "lot_number": "L1"},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"]
assert (await db_session.execute(select(Measurement))).scalars().all() == []
assert response.status_code == 200
assert [m["value"] for m in response.json()["measurements"]] == [None, None]
rows = (await db_session.execute(select(Measurement))).scalars().all()
assert len(rows) == 2 and all(r.value is None and r.pass_fail == "fail" for r in rows)
@pytest.mark.asyncio
async def test_a_null_output_counts_as_not_produced(
async def test_a_null_output_is_saved_as_nan(
client, db_session, measurement_tec_user, worker_says,
):
"""A worker answering with the key present but null (not omitted) must not
reach float(None) as a bare TypeError - it is "not produced", same as a
missing key (#16)."""
"""Chiave presente ma null (non omessa): NaN, non TypeError (#16, #118)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": None, "t1.radius": GOOD})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"]
assert (await db_session.execute(select(Measurement))).scalars().all() == []
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
@pytest.mark.asyncio
async def test_a_non_finite_output_counts_as_not_produced(
async def test_a_non_finite_output_is_saved_as_nan(
client, db_session, measurement_tec_user, worker_says,
):
"""NaN/inf must not become a saved measurement (#16)."""
"""NaN/inf non diventano un valore salvato: diventano NaN (NULL) (#16, #118)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
@pytest.mark.asyncio
async def test_a_nan_quote_still_blocks_the_next_acquisition(
client, db_session, measurement_tec_user, worker_says,
):
"""#118: la quota NaN e' un fail come gli altri, il blocco di tolleranza resta."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.radius": GOOD})
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
blocking = await measurement_service.pending_authorisation(
db_session, version.id, measurement_tec_user.id,
)
assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None
@pytest.mark.asyncio
async def test_check_lists_a_missing_quote_as_nan(
client, db_session, measurement_tec_user, worker_says,
):
"""#118: check -> una voce per quota, la non prodotta e' value null / fail."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.radius": GOOD})
response = await client.post(
"/api/vision/execute",
"/api/vision/check",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"]
assert response.status_code == 200
assert response.json()["measurements"] == [
{"subtask_id": first.id, "value": None, "pass_fail": "fail"},
{"subtask_id": second.id, "value": GOOD, "pass_fail": "pass"},
]
assert (await db_session.execute(select(Measurement))).scalars().all() == []