fix(vision): il gate del fuori tolleranza vale per l'acquisizione, non per la quota
Un'acquisizione camera produce N quote nello stesso istante: non c'e "andare avanti" fra l'una e l'altra, quindi il gate sequenziale di save_measurement non deve scattare dentro il lotto. execute_task valuta pending_authorisation una sola volta per l'intera acquisizione (bloccando solo se il fail pendente non appartiene alle quote di questo task) e passa enforce_tolerance_gate=False a ogni save_measurement del lotto. Query delle quote ordinata per marker_number, cosi l'esito non dipende dall'ordine di ritorno di SQLite/MySQL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
This commit is contained in:
@@ -225,8 +225,17 @@ async def save_measurement(
|
|||||||
input_duration_ms: int | None = None,
|
input_duration_ms: int | None = None,
|
||||||
production_run_id: int | None = None,
|
production_run_id: int | None = None,
|
||||||
vision_result_id: int | None = None,
|
vision_result_id: int | None = None,
|
||||||
|
enforce_tolerance_gate: bool = True,
|
||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Save a single measurement with auto-calculated pass/fail."""
|
"""Save a single measurement with auto-calculated pass/fail.
|
||||||
|
|
||||||
|
``enforce_tolerance_gate=False`` skips only the pending-authorisation check
|
||||||
|
below - everything else (verdict, recipe rules, deviation) still applies.
|
||||||
|
This is for a caller that has already evaluated the gate itself for a whole
|
||||||
|
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||||
|
the gate models "moving on" between measurements taken one after another,
|
||||||
|
which does not exist inside a single camera acquisition.
|
||||||
|
"""
|
||||||
# Get subtask for tolerance values
|
# Get subtask for tolerance values
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||||
@@ -241,17 +250,18 @@ async def save_measurement(
|
|||||||
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
||||||
# that same quote again is the way out that does not need the capoturno; the
|
# that same quote again is the way out that does not need the capoturno; the
|
||||||
# next quote is not.
|
# next quote is not.
|
||||||
blocking = await pending_authorisation(
|
if enforce_tolerance_gate:
|
||||||
db, version_id, measured_by, production_run_id,
|
blocking = await pending_authorisation(
|
||||||
)
|
db, version_id, measured_by, production_run_id,
|
||||||
if blocking is not None and blocking.subtask_id != subtask_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=(
|
|
||||||
"A measurement is out of tolerance and awaiting the supervisor: "
|
|
||||||
"authorise it or measure that quote again before going on"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
if blocking is not None and blocking.subtask_id != subtask_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,15 @@ async def execute_task(
|
|||||||
detail="this task carries no vision graph",
|
detail="this task carries no vision graph",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ordered by marker_number - the number the operator sees on the annotated
|
||||||
|
# image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py
|
||||||
|
# all key off it). Which quote is saved first must never be an accident of
|
||||||
|
# how SQLite/MySQL happens to return rows.
|
||||||
quotes = (await db.execute(
|
quotes = (await db.execute(
|
||||||
select(RecipeSubtask)
|
select(RecipeSubtask)
|
||||||
.where(RecipeSubtask.task_id == task.id)
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
.where(RecipeSubtask.vision_output.is_not(None))
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.order_by(RecipeSubtask.marker_number)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
if not quotes:
|
if not quotes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -79,6 +84,28 @@ async def execute_task(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ruling R10: the gate is evaluated once per acquisition, not once per
|
||||||
|
# quote. `save_measurement`'s own gate models "moving on" between
|
||||||
|
# measurements taken one after another - a real later moment where the
|
||||||
|
# operator could leave a bad reading behind. A camera acquisition produces
|
||||||
|
# every quote of this task at the same instant: there is no "moving on"
|
||||||
|
# between them, so applying the sequential rule inside the batch would
|
||||||
|
# block on a fail this very request just saved. Check it once here,
|
||||||
|
# against the whole set this acquisition is about to produce, and pass
|
||||||
|
# enforce_tolerance_gate=False to every save_measurement call below.
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
quote_ids = {q.id for q in quotes}
|
||||||
|
if blocking is not None and blocking.subtask_id not in quote_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
result = VisionResult(
|
result = VisionResult(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
engine_version=report["engine_version"],
|
engine_version=report["engine_version"],
|
||||||
@@ -102,6 +129,7 @@ async def execute_task(
|
|||||||
input_method="camera",
|
input_method="camera",
|
||||||
production_run_id=production_run_id,
|
production_run_id=production_run_id,
|
||||||
vision_result_id=result.id,
|
vision_result_id=result.id,
|
||||||
|
enforce_tolerance_gate=False,
|
||||||
))
|
))
|
||||||
|
|
||||||
return result, saved
|
return result, saved
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ from src.backend.models.orm.measurement import Measurement
|
|||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.vision import VisionResult
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.services import measurement_service
|
||||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
GOOD = 10.0 # inside every limit of the fixture quote
|
||||||
|
OUT = 99.0 # far outside
|
||||||
|
|
||||||
|
|
||||||
def an_image() -> bytes:
|
def an_image() -> bytes:
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
@@ -35,6 +39,29 @@ async def recipe_parts(db_session, recipe_id: int):
|
|||||||
return version, task, subtask
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
async def two_vision_quotes(db_session, recipe_id: int):
|
||||||
|
"""The fixture recipe's task, with a second quote mapped to a vision output.
|
||||||
|
|
||||||
|
Same shape as `test_out_of_tolerance.py::_two_quotes`, adapted so both
|
||||||
|
quotes belong to a single camera acquisition instead of two separate
|
||||||
|
manual readings.
|
||||||
|
"""
|
||||||
|
version, task, first = await recipe_parts(db_session, recipe_id)
|
||||||
|
first.vision_output = "t1.diameter"
|
||||||
|
|
||||||
|
second = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=2, description="Second quote",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
vision_output="t1.radius",
|
||||||
|
)
|
||||||
|
db_session.add(second)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(second)
|
||||||
|
return version, task, first, second
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def worker_says(monkeypatch):
|
def worker_says(monkeypatch):
|
||||||
"""The worker does not run in tests: its response is faked here."""
|
"""The worker does not run in tests: its response is faked here."""
|
||||||
@@ -151,3 +178,113 @@ async def test_a_task_without_a_graph_is_refused(
|
|||||||
|
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert "graph" in response.json()["detail"].lower()
|
assert "graph" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ruling R10: the gate is evaluated once per acquisition, not once per quote.
|
||||||
|
# A camera acquisition produces every quote of the task at the same instant -
|
||||||
|
# there is no "moving on" between them, so the sequential gate must not fire
|
||||||
|
# inside the batch. It still has to fire between two separate acquisitions.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_non_last_out_of_tolerance_quote_does_not_break_the_batch(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""The quote evaluated first fails; the one after it must still be saved."""
|
||||||
|
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": OUT, "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 == 200
|
||||||
|
by_subtask = {
|
||||||
|
m.subtask_id: m.pass_fail
|
||||||
|
for m in (await db_session.execute(select(Measurement))).scalars().all()
|
||||||
|
}
|
||||||
|
assert by_subtask == {first.id: "fail", second.id: "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_pending_fail_outside_the_batch_still_blocks(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""A fail left over from something else this camera task does not cover."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
# A third quote on the same task, not part of this acquisition (no vision
|
||||||
|
# output), already failed and is waiting on a supervisor.
|
||||||
|
third = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=3, description="Not a vision quote",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
)
|
||||||
|
db_session.add(third)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(third)
|
||||||
|
await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=third.id, version_id=version.id,
|
||||||
|
measured_by=measurement_tec_user.id, value=OUT,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
worker_says({"t1.diameter": GOOD, "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 == 409
|
||||||
|
assert "supervisor" in response.json()["detail"].lower()
|
||||||
|
assert (await db_session.execute(
|
||||||
|
select(Measurement).where(Measurement.subtask_id.in_([first.id, second.id]))
|
||||||
|
)).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_new_acquisition_can_remeasure_its_own_pending_quote(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""Re-shooting the same task is the way out, same as re-measuring by hand."""
|
||||||
|
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": OUT, "t1.radius": GOOD})
|
||||||
|
first_shot = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p1.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert first_shot.status_code == 200
|
||||||
|
|
||||||
|
# first.id is now pending authorisation - but it belongs to this same
|
||||||
|
# task, so a new acquisition of it is not "moving on".
|
||||||
|
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
|
||||||
|
second_shot = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p2.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert second_shot.status_code == 200
|
||||||
|
|
||||||
|
readings = (await db_session.execute(
|
||||||
|
select(Measurement)
|
||||||
|
.where(Measurement.subtask_id == first.id)
|
||||||
|
.order_by(Measurement.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert [m.pass_fail for m in readings] == ["fail", "pass"]
|
||||||
|
|
||||||
|
assert await measurement_service.pending_authorisation(
|
||||||
|
db_session, version.id, measurement_tec_user.id,
|
||||||
|
) is None
|
||||||
|
|||||||
Reference in New Issue
Block a user