Unione lavoro/daitarn-tmf-nan (Refs #118)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
This commit is contained in:
2026-10-28 21:41:43 +01:00
18 changed files with 353 additions and 76 deletions
+2 -1
View File
@@ -357,7 +357,8 @@ async def export_measurements_csv(
# Rows # Rows
for m in measurements: for m in measurements:
# Format decimals with configured separator # 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 = ( deviation_str = (
str(m.deviation).replace(".", decimal_separator) str(m.deviation).replace(".", decimal_separator)
if m.deviation is not None 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, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58 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) tol = await _get_subtask_tolerances(db, subtask_id)
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"]) 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, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58 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] values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements] timestamps = [m.measured_at for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id) 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, date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58 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) return compute_histogram(values, n_bins)
+4 -1
View File
@@ -162,7 +162,10 @@ async def execute(
engine_version=result.engine_version, engine_version=result.engine_version,
measurements=[ measurements=[
VisionMeasurementResult( 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 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 subtask_id: int
version_id: int version_id: int
measured_by: int measured_by: int
value: float value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
pass_fail: str pass_fail: str
deviation: Optional[float] = None deviation: Optional[float] = None
lot_number: Optional[str] = None lot_number: Optional[str] = None
@@ -108,7 +108,7 @@ class CycleMeasurementResponse(BaseModel):
"""One quote already measured in the cycle under way.""" """One quote already measured in the cycle under way."""
subtask_id: int subtask_id: int
value: float value: Optional[float] = None # #118: null = NaN
pass_fail: str pass_fail: str
measured_at: UtcDateTime measured_at: UtcDateTime
+1 -1
View File
@@ -12,7 +12,7 @@ class MonitorSubtask(BaseModel):
description: str description: str
unit: str unit: str
count: int count: int
mean: float mean: Optional[float] = None # #118: None = solo quote NaN
ltl: Optional[float] = None ltl: Optional[float] = None
utl: Optional[float] = None utl: Optional[float] = None
# True when ltl <= mean <= utl (a missing limit does not constrain). # True when ltl <= mean <= utl (a missing limit does not constrain).
+1 -1
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
class VisionMeasurementResult(BaseModel): class VisionMeasurementResult(BaseModel):
"""One quote's outcome, as much of it as the client needs to show.""" """One quote's outcome, as much of it as the client needs to show."""
subtask_id: int subtask_id: int
value: float value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail"
pass_fail: str pass_fail: str
+2 -1
View File
@@ -26,7 +26,8 @@ class Measurement(Base):
) )
# Measurement data # 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( pass_fail: Mapped[str] = mapped_column(
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
) )
+10 -2
View File
@@ -302,7 +302,7 @@ async def current_cycle_measurements(
return [ return [
{ {
"subtask_id": m.subtask_id, "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, "pass_fail": m.pass_fail,
"measured_at": m.measured_at, "measured_at": m.measured_at,
} }
@@ -355,7 +355,7 @@ async def save_measurement(
subtask_id: int, subtask_id: int,
version_id: int, version_id: int,
measured_by: int, measured_by: int,
value: float, value: float | None,
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
input_method: str = "manual", input_method: str = "manual",
@@ -372,7 +372,11 @@ async def save_measurement(
batch of quotes taken at the same instant (see Ruling R10 / vision_service): batch of quotes taken at the same instant (see Ruling R10 / vision_service):
the gate models "moving on" between measurements taken one after another, the gate models "moving on" between measurements taken one after another,
which does not exist inside a single camera acquisition. 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) _validate_measured_value(value)
# Get subtask for tolerance values # Get subtask for tolerance values
@@ -421,6 +425,10 @@ async def save_measurement(
# Rounded once, here, so the stored value is exactly the one the verdict # Rounded once, here, so the stored value is exactly the one the verdict
# above was computed on - calculate_pass_fail rounds internally too, but # above was computed on - calculate_pass_fail rounds internally too, but
# matching it here keeps the persisted row from disagreeing with itself. # 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) value = round(value, 6)
pass_fail, deviation = calculate_pass_fail(value, subtask) pass_fail, deviation = calculate_pass_fail(value, subtask)
+10 -3
View File
@@ -96,14 +96,21 @@ async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
per_sub[row.subtask_id].append(row) per_sub[row.subtask_id].append(row)
subtasks = [] subtasks = []
for sid, group in sorted(per_sub.items()): 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; con soli
# NaN la quota resta in elenco con mean None ed e' fuori tolleranza
numeric = [float(g.value) for g in group if g.value is not None]
mean = sum(numeric) / len(numeric) if numeric else None
first = group[0] first = group[0]
subtasks.append(MonitorSubtask( subtasks.append(MonitorSubtask(
subtask_id=sid, description=first.description, unit=first.unit, subtask_id=sid, description=first.description, unit=first.unit,
count=len(group), mean=round(mean, 6), count=len(group),
mean=round(mean, 6) if mean is not None else None, # #118
ltl=float(first.ltl) if first.ltl is not None else None, ltl=float(first.ltl) if first.ltl is not None else None,
utl=float(first.utl) if first.utl is not None else None, utl=float(first.utl) if first.utl is not None else None,
in_tolerance=_in_tolerance(mean, first.ltl, first.utl), in_tolerance=(
_in_tolerance(mean, first.ltl, first.utl)
if mean is not None else False # #118: tutte NaN = fail
),
)) ))
timer = production_service.describe(run) timer = production_service.describe(run)
ev = last_event.get(run.id) ev = last_event.get(run.id)
@@ -159,7 +159,7 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
subtask.marker_number if subtask else "", subtask.marker_number if subtask else "",
neutralise_csv_cell(subtask.description) if subtask else "", neutralise_csv_cell(subtask.description) if subtask else "",
number(subtask.nominal) 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), number(measurement.deviation),
measurement.pass_fail, measurement.pass_fail,
number(subtask.ltl) if subtask else "", number(subtask.ltl) if subtask else "",
+5 -3
View File
@@ -318,8 +318,10 @@ async def generate_spc_report(
# 2. Compute SPC data # 2. Compute SPC data
pass_fail_values = [m.pass_fail for m in measurements] pass_fail_values = [m.pass_fail for m in measurements]
values = [float(m.value) for m in measurements] # #118: le quote NaN (value None) contano nell'esito ma non nei calcoli
timestamps = [m.measured_at for m in measurements] 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) summary = compute_summary(pass_fail_values)
@@ -426,7 +428,7 @@ async def generate_measurement_report(
rows.append({ rows.append({
"num": i, "num": i,
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}", "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"), "unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail, "pass_fail": m.pass_fail,
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"), "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"] outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse # #118: una quota che il grafo non produce non blocca piu' l'esecuzione
# than a piece not measured at all. A quote counts as produced only if its # (prima 422 "the graph did not produce"): viene salvata con value NULL,
# output is present *and* a finite, non-bool number: the in-process runner # mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato.
# already drops NaN/inf itself (see src/vision/runner.py), but the worker # Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero
# is reached over HTTP - a different implementation of it, or a malformed # finito non-bool: il worker risponde via HTTP, un'altra implementazione o
# response, could still hand back None or a non-finite float for a mapped # una risposta malformata potrebbe dare None o un non finito per una chiave
# key instead of omitting it outright. # mappata invece di ometterla.
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 "")
),
)
overlay = report.get("overlay") or [] overlay = report.get("overlay") or []
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext) 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, subtask_id=quote.id,
version_id=version_id, version_id=version_id,
measured_by=measured_by, 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, lot_number=lot_number,
serial_number=serial_number, serial_number=serial_number,
input_method="camera", input_method="camera",
@@ -762,8 +756,9 @@ async def check_task(
VisionResult, niente file, niente misure. L'esito per quota usa VisionResult, niente file, niente misure. L'esito per quota usa
calculate_pass_fail sulle quote con vision_output, nell'ordine di calculate_pass_fail sulle quote con vision_output, nell'ordine di
marker_number come execute. Le quote che il grafo non produce (pezzo non 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 ancora in posizione) entrano in `measurements` con `value` None (NaN) e
`failures`: un'anteprima non deve dare 422 a ogni frame. `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: if task.task_type not in VISION_TASK_TYPES:
raise HTTPException( raise HTTPException(
@@ -809,6 +804,7 @@ async def check_task(
**worker_kwargs, **worker_kwargs,
) )
outputs = report["outputs"] outputs = report["outputs"]
# #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail
measurements = [ measurements = [
{ {
"subtask_id": q.id, "subtask_id": q.id,
@@ -816,8 +812,10 @@ async def check_task(
"pass_fail": measurement_service.calculate_pass_fail( "pass_fail": measurement_service.calculate_pass_fail(
float(outputs[q.vision_output]), q, float(outputs[q.vision_output]), q,
)[0], )[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 { return {
"outcome": report.get("outcome"), "outcome": report.get("outcome"),
+19
View File
@@ -360,3 +360,22 @@ class TestTaskAcquisitionMigration:
assert "camera_url" in cols("stations") # la 020 resta assert "camera_url" in cols("stations") # la 020 resta
command.upgrade(cfg, "head") # e di nuovo su command.upgrade(cfg, "head") # e di nuovo su
assert "acquisition_json" in cols("recipe_tasks") 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
+26
View File
@@ -81,3 +81,29 @@ async def test_open_lists_only_open_runs_with_means(client, db_session, admin_us
async def test_open_empty_for_admin(client, admin_user): async def test_open_empty_for_admin(client, admin_user):
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user)) resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
assert resp.status_code == 200 and resp.json() == [] assert resp.status_code == 200 and resp.json() == []
async def test_nan_quotes_in_the_monitor(client, db_session, admin_user):
"""#118: solo NaN -> la quota resta, mean null e fuori tolleranza; mista -> media dei numeri."""
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
r1, s1, v1 = await _run(db_session, admin_user, "N1")
r2, s2, v2 = await _run(db_session, admin_user, "N2")
for i in range(2):
db_session.add(_m(r1, s1, v1, admin_user, None, "fail", i))
for i, (val, pf) in enumerate([(None, "fail"), (10.0, "pass"), (10.2, "pass")]):
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
await db_session.commit()
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
assert resp.status_code == 200
body = {r["station_code"]: r for r in resp.json()}
only_nan = body["ST-N1"]["subtasks"]
assert len(only_nan) == 1
assert only_nan[0]["mean"] is None
assert only_nan[0]["count"] == 2
assert only_nan[0]["in_tolerance"] is False
assert body["ST-N1"]["fail_count"] == 2
mixed = body["ST-N2"]["subtasks"][0]
assert mixed["count"] == 3
assert round(mixed["mean"], 6) == 10.1
assert mixed["in_tolerance"] 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")] == ""
+5 -2
View File
@@ -244,7 +244,7 @@ async def test_check_calibration_reasons(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_omits_quotes_the_graph_did_not_produce( async def test_check_lists_quotes_the_graph_did_not_produce_as_nan(
client, db_session, measurement_tec_user, monkeypatch, client, db_session, measurement_tec_user, monkeypatch,
): ):
_, task = await _camera_task(db_session, measurement_tec_user.id) _, task = await _camera_task(db_session, measurement_tec_user.id)
@@ -255,7 +255,10 @@ async def test_check_omits_quotes_the_graph_did_not_produce(
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call) monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task) resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
assert resp.json()["measurements"] == [] # #118: la quota non prodotta c'e', value null (NaN) e fail
measurements = resp.json()["measurements"]
assert len(measurements) == 1
assert measurements[0]["value"] is None and measurements[0]["pass_fail"] == "fail"
assert resp.json()["failures"] == [{"tool_name": "blob"}] assert resp.json()["failures"] == [{"tool_name": "blob"}]
+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 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 @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, 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) recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id)
task.task_type = "camera_measure" worker_says({"t1.radius": GOOD}, failures=[{"tool_name": "arco_tr"}])
task.vision_json = {"schema_version": 1, "name": "p", "tools": []} await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
subtask.vision_output = "t1.diameter"
await db_session.commit()
worker_says({"t1.radius": 5.0}) @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( response = await client.post(
"/api/vision/execute", "/api/vision/execute",
headers=auth_headers(measurement_tec_user), headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")}, 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 response.status_code == 200
assert "t1.diameter" in response.json()["detail"] assert [m["value"] for m in response.json()["measurements"]] == [None, None]
assert (await db_session.execute(select(Measurement))).scalars().all() == [] 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 @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, client, db_session, measurement_tec_user, worker_says,
): ):
"""A worker answering with the key present but null (not omitted) must not """Chiave presente ma null (non omessa): NaN, non TypeError (#16, #118)."""
reach float(None) as a bare TypeError - it is "not produced", same as a
missing key (#16)."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id) recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": None, "t1.radius": GOOD}) worker_says({"t1.diameter": None, "t1.radius": GOOD})
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
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() == []
@pytest.mark.asyncio @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, 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) recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id)
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD}) 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( response = await client.post(
"/api/vision/execute", "/api/vision/check",
headers=auth_headers(measurement_tec_user), headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")}, files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)}, data={"task_id": str(task.id)},
) )
assert response.status_code == 422 assert response.status_code == 200
assert "t1.diameter" in response.json()["detail"] 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() == [] assert (await db_session.execute(select(Measurement))).scalars().all() == []