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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,8 +372,12 @@ 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.
|
||||||
"""
|
"""
|
||||||
_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
|
# Get subtask for tolerance values
|
||||||
result = await db.execute(
|
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
|
# 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.
|
||||||
value = round(value, 6)
|
if value is None:
|
||||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
# #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(
|
measurement = Measurement(
|
||||||
subtask_id=subtask_id,
|
subtask_id=subtask_id,
|
||||||
|
|||||||
@@ -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 "",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")] == ""
|
||||||
@@ -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"}]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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() == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user