Compare commits

..

1 Commits

Author SHA1 Message Date
Adriano ad6ce5d11d Backend: ricetta DXF compare da camera e calibrazione letta dall'acquisitore (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:44:15 +02:00
5 changed files with 624 additions and 668 deletions
-458
View File
@@ -1,458 +0,0 @@
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
Cosa fa
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
UTENTI qui sotto, decise da Adriano per la fiera);
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
tolleranza);
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
(si crea in fiera dal pannello «Crea ricetta da DXF»).
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
[--fine AAAA-MM-GG]
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
comando e stessa data di fine, stessi dati.
Da cosa dipende
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
"""
import argparse
import asyncio
import random
import sys
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from sqlalchemy import delete, event, select
from sqlalchemy.ext.asyncio import (
AsyncSession, async_sessionmaker, create_async_engine,
)
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.backend.database import async_session_factory # noqa: E402
from src.backend.models.orm.measurement import Measurement # noqa: E402
from src.backend.models.orm.production import ( # noqa: E402
ProductionEvent, ProductionRun,
)
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
from src.backend.models.orm.station import ( # noqa: E402
Station, StationRecipeAssignment,
)
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
from src.backend.models.orm.task_result import TaskResult # noqa: E402
from src.backend.models.orm.user import User # noqa: E402
from src.backend.services import auth_service # noqa: E402
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
CAMERA_URL = "http://127.0.0.1:8765"
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
SEED = 20261026 # seme fisso: dati ripetibili
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
GIORNI_STORICO = 28 # circa 4 settimane
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
UTENTI = [
("adriano", "123", "Adriano Dal Pastro",
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
("capoturno", "123", "Luca Bianchi (Capoturno)",
["Supervisor", "MeasurementTec"], False),
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
]
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
return dict(
marker_number=marker, description=descrizione, nominal=nominale,
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
unit=unita,
)
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
FLANGIA = [
# (task, quota, scostamento, sigma, deriva)
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
]
ALBERO = [
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
]
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
return locale.astimezone(timezone.utc).replace(tzinfo=None)
def _giorni_lavorativi(fine: date) -> list[date]:
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
return sorted(g for g in giorni if g.weekday() < 5)
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
scelti = sorted(rng.sample(mezz_ore, n))
return [m + rng.randint(0, 9) for m in scelti]
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
utenti = {}
for username, password, nome, ruoli, admin in UTENTI:
esistente = (await session.execute(
select(User).where(User.username == username)
)).scalar_one_or_none()
if esistente is None:
utenti[username] = await auth_service.create_user(
session, username=username, password=password, display_name=nome,
roles=ruoli, is_admin=admin,
)
else:
esistente.password_hash = auth_service.hash_password(password)
esistente.display_name = nome
esistente.roles = ruoli
esistente.is_admin = admin
esistente.active = True
utenti[username] = esistente
await session.flush()
return utenti
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
stazione = (await session.execute(
select(Station).where(Station.code == STATION_CODE)
)).scalar_one_or_none()
if stazione is None:
stazione = Station(
code=STATION_CODE, name="Postazione fiera", location="Stand",
created_by=owner.id,
)
session.add(stazione)
stazione.camera_url = CAMERA_URL
stazione.camera_source = CAMERA_SOURCE
await session.flush()
return stazione
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
for ricetta in ricette:
versioni = (await session.execute(
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
)).scalars().all()
runs = (await session.execute(
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
)).scalars().all()
if versioni:
conti["misure"] += (await session.execute(
delete(Measurement).where(Measurement.version_id.in_(versioni))
)).rowcount
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
)
if runs:
conti["eventi"] += (await session.execute(
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
)).rowcount
conti["produzioni"] += (await session.execute(
delete(ProductionRun).where(ProductionRun.id.in_(runs))
)).rowcount
await session.execute(
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
)
await session.execute(
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
)
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
await session.delete(ricetta)
await session.flush()
return conti
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
interval, requires_serial, note_versione):
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
ricetta = Recipe(
code=code, name=name, description=description, created_by=owner.id, active=True,
measurement_interval_minutes=interval, requires_lot=True,
requires_serial=requires_serial, allow_manual_input=True,
)
session.add(ricetta)
await session.flush()
versione = RecipeVersion(
recipe_id=ricetta.id, version_number=1, is_current=True,
created_by=owner.id, change_notes=note_versione,
)
session.add(versione)
session.add(StationRecipeAssignment(
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
))
await session.flush()
return ricetta, versione
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
descrizione=None, quote=()):
task = RecipeTask(
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
directive=direttiva, description=descrizione,
)
session.add(task)
await session.flush()
sottotask = []
for q in quote:
st = RecipeSubtask(task_id=task.id, **q)
session.add(st)
sottotask.append(st)
await session.flush()
return task, sottotask
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
interval, requires_serial, profilo, titoli_task):
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
ricetta, versione = await _nuova_ricetta(
session, owner, stazione, code=code, name=name, description=description,
interval=interval, requires_serial=requires_serial,
note_versione="Ricetta dimostrativa per la fiera",
)
await _task(session, versione, 0, "Preparazione del pezzo", "note",
direttiva="Prima di misurare",
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
sottotask_per_quota = {}
for indice, titolo in enumerate(titoli_task):
quote = [q for (t, q, *_r) in profilo if t == indice]
_t, sottotask = await _task(
session, versione, indice + 1, titolo, "measure",
direttiva="Misurare le quote in sequenza", quote=quote,
)
for st in sottotask:
sottotask_per_quota[st.marker_number] = st
return ricetta, versione, sottotask_per_quota
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
tol = quota["utl"] - quota["nominal"]
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
return round(rng.gauss(media, tol * sigma), 6)
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
giorni = _giorni_lavorativi(fine)
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
task_ids = {} # marker -> id del task di misura
for marker, st in sottotask.items():
task_ids[marker] = st.task_id
ordine_task = sorted(set(task_ids.values()))
ultimo_task = ordine_task[-1]
contatore = 0
totale = 0
for indice_giorno, giorno in enumerate(giorni):
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
n = rng.randint(*pezzi_giorno)
slot = _slot_giornalieri(rng, n)
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
run = ProductionRun(
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
operator_id=operatore.id, lot_number=lotto, status="closed",
measurement_interval_minutes=ricetta.measurement_interval_minutes,
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
active_station_id=None,
)
session.add(run)
await session.flush()
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
created_at=inizio))
for numero, minuti in enumerate(slot, start=1):
contatore += 1
seriale = f"{prefisso_seriale}{contatore:05d}"
istante = _ora_utc(giorno, minuti)
for marker in sorted(sottotask):
st = sottotask[marker]
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
esito, scarto = calculate_pass_fail(valore, st)
m = Measurement(
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
serial_number=seriale,
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
input_duration_ms=rng.randint(2500, 9000),
production_run_id=run.id,
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
supervisor_id=capoturno.id if esito == "fail" else None,
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
measured_at=istante + timedelta(seconds=20 * marker),
)
session.add(m)
totale += 1
# un task_measured per ogni task di misura non finale, poi il ciclo completato
for tid in ordine_task:
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
session.add(ProductionEvent(
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
created_at=istante + timedelta(minutes=1),
))
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
supervisor_id=capoturno.id, created_at=fine_run))
await session.flush()
return contatore, totale
async def _ricetta_dxf(session, owner, stazione):
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
ricetta, versione = await _nuova_ricetta(
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
description="Confronto di un profilo con il disegno DXF tramite la camera. "
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
)
await _task(session, versione, 0, "Preparazione del pezzo", "note",
direttiva="Prima di misurare",
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
await _task(
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
direttiva="Acquisire e confrontare",
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
)
return ricetta
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
rng = random.Random(SEED)
async with async_session_factory() as session:
utenti = await _upsert_utenti(session)
owner = utenti["adriano"]
stazione = await _upsert_stazione(session, owner)
esistenti = list((await session.execute(
select(Recipe).where(Recipe.code.in_(CODES))
)).scalars().all())
if esistenti and not replace:
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
"non duplico. Usa --replace per rifarle.")
else:
if esistenti:
conti = await _conta_e_cancella(session, esistenti)
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
f"{conti['eventi']} eventi)")
r1, v1, sub1 = await _ricetta_meccanica(
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
"e profondita' della lamatura. Lotto e seriale obbligatori.",
interval=30, requires_serial=True, profilo=FLANGIA,
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
)
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
FLANGIA, (10, 14), "LF", "FL")
r2, v2, sub2 = await _ricetta_meccanica(
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
"rugosita'. Tolleranze strette.",
interval=30, requires_serial=False, profilo=ALBERO,
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
)
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
ALBERO, (8, 11), "LA", "AL")
await _ricetta_dxf(session, owner, stazione)
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
if dry_run:
await session.rollback()
print("--dry-run: nessuna modifica salvata.")
else:
await session.commit()
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
f"{', '.join(u[0] for u in UTENTI)}).")
return 0
def _factory_per_file(db: Path):
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
@event.listens_for(engine.sync_engine, "connect")
def _pragma(dbapi_connection, _record) -> None:
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.execute("PRAGMA busy_timeout=5000")
cur.close()
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def _main_async(args) -> int:
global async_session_factory
engine, async_session_factory = _factory_per_file(args.db)
try:
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
return await seed(args.replace, args.dry_run, fine)
finally:
await engine.dispose()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
parser.add_argument("--replace", action="store_true",
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
args = parser.parse_args()
if not args.db.is_file():
print(f"File non trovato: {args.db}")
return 2
return asyncio.run(_main_async(args))
if __name__ == "__main__":
raise SystemExit(main())
+103 -14
View File
@@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import base64
import binascii
import io
import json
from typing import Literal
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
@@ -26,7 +29,7 @@ from src.backend.models.api.vision import (
from src.backend.models.orm.vision import VisionResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
from src.backend.services import station_service, vision_service
router = APIRouter(prefix="/api/vision", tags=["vision"])
@@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException:
)
async def _store_validate_assign(
task: RecipeTask, graph: dict, placed: dict[str, bytes],
) -> tuple[str, dict]:
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
"""
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
return recipe_dir, check
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
async def upload_recipe(
task_id: int,
@@ -218,19 +245,7 @@ async def upload_recipe(
if errors:
raise _422(errors)
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
recipe_dir, check = await _store_validate_assign(task, graph, placed)
await db.commit()
return VisionRecipeUploadResponse(
task_id=task.id,
@@ -325,3 +340,77 @@ async def preview(
) -> dict:
task = await _task_or_404(db, task_id)
return await vision_service.preview(db, task, await _read_image(image))
@router.post("/tasks/{task_id}/recipe/dxf-compare")
async def create_dxf_compare_recipe(
task_id: int,
image: UploadFile = File(...),
dxf: UploadFile = File(...),
station_id: int | None = Form(None, gt=0),
px_per_mm: float | None = Form(None, gt=0),
band_mm: float = Form(0.1, gt=0),
min_points_pct: float = Form(95, ge=0, le=100),
polarity: Literal["dark", "light"] = Form("dark"),
save: bool = Form(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
Con save=false si vede il risultato e non si scrive nulla; con save=true la
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
visione nasce la quota «Punti in tolleranza».
"""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
station = None
if station_id is not None:
station = await station_service.get_station(db, station_id) # 404 se non esiste
if px_per_mm is None:
calibration, reason = await vision_service.fetch_station_calibration(station)
if calibration is None:
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
raise _422([
"acquisitore della stazione non raggiungibile"
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
])
px_per_mm = float(calibration["px_per_mm"])
image_bytes = await _read_image(image)
dxf_bytes = await dxf.read()
if not validate_file_size(len(dxf_bytes)):
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
body = await vision_service.dxf_compare_recipe(
image_bytes, dxf_bytes,
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
polarity=polarity,
worker_url=(station.vision_worker_url or None) if station is not None else None,
)
# i file stanno nella ricetta salvata, non nella risposta al client
files = body.pop("files")
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
if not save:
return response
uploads: list[tuple[str, bytes]] = []
for name, encoded in files.items():
try:
uploads.append((name, base64.b64decode(encoded, validate=True)))
except (binascii.Error, ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
)
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
if errors:
raise _422(errors)
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
await vision_service.ensure_points_quote(db, task, min_points_pct)
await db.commit()
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
return response
+183 -2
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import math
from pathlib import Path
from uuid import uuid4
@@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
logger = logging.getLogger(__name__)
# Worker responses whose status and reason are forwarded as-is rather than
# flattened into a generic 502 - see `_call_worker`.
_FORWARDED_WORKER_STATUSES = frozenset({
@@ -140,6 +143,136 @@ async def _call_worker(
return report
async def call_worker_multipart(
path: str,
*,
files: dict,
data: dict,
worker_url: str | None = None,
timeout: float = 120.0,
) -> dict:
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
"""
base = (worker_url or settings.vision_worker_url).rstrip("/")
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(f"{base}{path}", files=files, data=data)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc} ({base})",
) from exc
except (httpx.TransportError, httpx.HTTPError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc} ({base})",
) from exc
if response.status_code in _FORWARDED_WORKER_STATUSES:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
raise HTTPException(status_code=response.status_code, detail=detail)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker refused the job: {response.text}",
)
try:
body = response.json()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
) from exc
if not isinstance(body, dict):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: expected a JSON object",
)
return body
async def dxf_compare_recipe(
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
min_points_pct: float, polarity: str, worker_url: str | None = None,
) -> dict:
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
body = await call_worker_multipart(
"/recipes/dxf-compare",
files={
"image": ("frame.png", image_bytes, "image/png"),
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
},
data={
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
"min_points_pct": str(min_points_pct), "polarity": polarity,
},
worker_url=worker_url,
)
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing graph or files",
)
return body
# Stati di fetch_station_calibration (#83)
CAL_OK = "ok"
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
Un px_per_mm non finito o <= 0 conta come non calibrata.
"""
if station is None or not station.camera_url or not station.camera_source:
return None, CAL_NO_CAMERA
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
try:
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url, params={"source": station.camera_source})
except httpx.HTTPError as exc:
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
return None, CAL_UNREACHABLE
if response.status_code == status.HTTP_404_NOT_FOUND:
return None, CAL_NOT_CALIBRATED
if response.status_code != 200:
logger.warning(
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
)
return None, CAL_UNREACHABLE
try:
body = response.json()
except ValueError:
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
return None, CAL_UNREACHABLE
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
return None, CAL_NOT_CALIBRATED
return body, CAL_OK
async def get_station_calibration(station: Station | None) -> dict | None:
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
calibration, _ = await fetch_station_calibration(station)
return calibration
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
@@ -365,11 +498,14 @@ async def execute_task(
)
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
calibration = None
if run is not None:
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
calibration = await get_station_calibration(station)
if not task.vision_json:
raise HTTPException(
@@ -419,11 +555,15 @@ async def execute_task(
),
)
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
# passa `calibration`, la ricetta porta la sua (#46).
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
# non si passa `calibration` e la ricetta porta la sua (#46).
worker_kwargs: dict = {}
if calibration is not None:
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
worker_url=station_url, # #45
**worker_kwargs,
)
outputs = report["outputs"]
@@ -457,6 +597,8 @@ async def execute_task(
station_id=run.station_id if run is not None else None,
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
graph_snapshot=task.vision_json,
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
calibration_snapshot=calibration,
duration_ms=report.get("duration_ms"),
)
db.add(result)
@@ -601,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
return await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
async def ensure_points_quote(
db: AsyncSession, task: RecipeTask, min_points_pct: float,
) -> bool:
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
"""
from src.backend.services import recipe_service # import locale: evita cicli
existing = (await db.execute(
select(RecipeSubtask.id)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
.limit(1)
)).scalar_one_or_none()
if existing is not None:
return False
if await recipe_service.version_has_measurements(db, task.version_id):
return False
markers = (await db.execute(
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
)).scalars().all()
db.add(RecipeSubtask(
task_id=task.id,
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
description="Punti in tolleranza",
nominal=100,
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
ltl=min_points_pct,
utl=100,
unit="%",
vision_output="dxf.points_in_pct",
))
await db.flush()
return True
-194
View File
@@ -1,194 +0,0 @@
"""Test di scripts/seed_fiera.py (issue #85): il seed del PC della fiera.
Lo script non e' un modulo del pacchetto: si carica da file con importlib, come
test_seed_collaudo. La parte "dato" gira sul DB in memoria dei test; l'ultimo test
percorre invece la strada vera (file SQLite con `alembic upgrade head` + --db).
"""
import importlib.util
import sqlite3
from argparse import Namespace
from datetime import date
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import func, select
from src.backend.config import Settings, settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import Recipe
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.models.orm.user import User
from src.backend.services.auth_service import authenticate_user
from src.backend.tests.conftest import TestSessionFactory, test_engine
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_fiera.py"
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
FINE = date(2026, 10, 22) # data fissa: il test non dipende dal giorno in cui gira
@pytest.fixture
def seed_fiera(monkeypatch):
spec = importlib.util.spec_from_file_location("seed_fiera", _SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
return module
async def _utente(db_session, username) -> User:
return (await db_session.execute(
select(User).where(User.username == username)
)).scalar_one()
class TestSeedFiera:
async def test_utenti_ruoli_e_login(self, seed_fiera, db_session):
assert await seed_fiera.seed(False, False, FINE) == 0
attesi = {
"adriano": ("123", True),
"maker": ("123", False),
"capoturno": ("123", False),
"operatore": ("123", False),
}
for username, (password, admin) in attesi.items():
utente = await authenticate_user(db_session, username, password)
assert utente is not None, username
assert utente.is_admin is admin
assert (await _utente(db_session, "maker")).roles == ["Maker"]
assert "Supervisor" in (await _utente(db_session, "capoturno")).roles
assert (await _utente(db_session, "operatore")).roles == ["MeasurementTec"]
assert {"Maker", "Metrologist"} <= set((await _utente(db_session, "adriano")).roles)
async def test_setup_risulta_completato(self, seed_fiera, client, monkeypatch):
import src.backend.api.routers.setup as setup_mod
monkeypatch.setattr(settings, "setup_password", "test-setup-password")
monkeypatch.setattr(setup_mod, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
# prima del seed il frontend vede il setup da fare, dopo no
assert (await client.get("/api/setup/status")).json() == {"initialised": False}
await seed_fiera.seed(False, False, FINE)
assert (await client.get("/api/setup/status")).json() == {"initialised": True}
async def test_stazione_e_ricette(self, seed_fiera, db_session):
await seed_fiera.seed(False, False, FINE)
stazione = (await db_session.execute(
select(Station).where(Station.code == "ST-DEFAULT")
)).scalar_one()
assert stazione.camera_url == "http://127.0.0.1:8765"
assert stazione.camera_source == "camera:601020"
assegnate = (await db_session.execute(
select(Recipe.code)
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
.where(StationRecipeAssignment.station_id == stazione.id)
)).scalars().all()
assert sorted(assegnate) == ["FIERA-ALBERO", "FIERA-DXF", "FIERA-FLANGIA"]
# la ricetta DXF ha un task dxf_compare e nessun grafo di visione
from src.backend.models.orm.task import RecipeTask
dxf = (await db_session.execute(
select(RecipeTask).where(RecipeTask.task_type == "dxf_compare")
)).scalars().all()
assert len(dxf) == 1 and dxf[0].vision_json is None
async def test_misure_nel_numero_atteso(self, seed_fiera, db_session):
await seed_fiera.seed(False, False, FINE)
# ogni pezzo ha una misura per quota: pezzi 150-300 per ricetta
for codice, quote in (("FIERA-FLANGIA", 5), ("FIERA-ALBERO", 4)):
misure = (await db_session.execute(
select(func.count(Measurement.id))
.where(Measurement.version_id.in_(
select(seed_fiera.RecipeVersion.id)
.join(Recipe, Recipe.id == seed_fiera.RecipeVersion.recipe_id)
.where(Recipe.code == codice)
))
)).scalar_one()
assert misure % quote == 0
assert 150 <= misure // quote <= 300, (codice, misure // quote)
# lun-ven soltanto, e le misure compaiono nelle produzioni
giorni = (await db_session.execute(
select(seed_fiera.ProductionRun.started_at)
)).scalars().all()
assert giorni and all(g.weekday() < 5 for g in giorni)
senza_run = (await db_session.execute(
select(func.count(Measurement.id)).where(Measurement.production_run_id.is_(None))
)).scalar_one()
assert senza_run == 0
async def test_statistiche_numeriche(self, seed_fiera, client, db_session):
await seed_fiera.seed(False, False, FINE)
adriano = await _utente(db_session, "adriano")
from src.backend.services.auth_service import issue_api_key
chiave = await issue_api_key(db_session, adriano)
intestazioni = {"X-API-Key": chiave}
flangia = (await db_session.execute(
select(Recipe).where(Recipe.code == "FIERA-FLANGIA")
)).scalar_one()
quote = (await client.get(
"/api/statistics/subtasks", headers=intestazioni,
params={"recipe_id": flangia.id},
)).json()
assert len(quote) == 5
cpk = []
for quota in quote:
risposta = await client.get(
"/api/statistics/capability", headers=intestazioni,
params={"recipe_id": flangia.id, "subtask_id": quota["id"]},
)
assert risposta.status_code == 200
d = risposta.json()
for campo in ("mean", "std_dev", "cp", "cpk"):
assert isinstance(d[campo], (int, float)), (quota["id"], campo)
cpk.append(d["cpk"])
# Cpk diversi per quota: una capace, una al limite o sotto
assert max(cpk) > 1.4
assert min(cpk) < 1.1
async def test_secondo_giro_non_duplica_e_replace_rifa(self, seed_fiera, db_session):
async def conta():
return [
(await db_session.execute(select(func.count(t.id)))).scalar_one()
for t in (Recipe, Measurement, User)
]
await seed_fiera.seed(False, False, FINE)
primo = await conta()
await seed_fiera.seed(False, False, FINE)
assert await conta() == primo # utenti aggiornati, ricette e misure non duplicate
await seed_fiera.seed(True, False, FINE)
assert await conta() == primo # stessi numeri: il seme e' fisso
async def test_dry_run_non_salva(self, seed_fiera, db_session):
await seed_fiera.seed(False, True, FINE)
assert (await db_session.execute(select(func.count(User.id)))).scalar_one() == 0
assert (await db_session.execute(select(func.count(Measurement.id)))).scalar_one() == 0
async def test_file_sqlite_con_alembic(seed_fiera, tmp_path, monkeypatch):
"""La strada vera: schema da `alembic upgrade head`, poi il seed con --db."""
db = tmp_path / "tmf.db"
url = f"sqlite+aiosqlite:///{db.as_posix()}"
monkeypatch.setattr(Settings, "database_url", property(lambda self: url))
# alembic fa asyncio.run(): fuori dal loop di pytest-asyncio, in un thread
import asyncio
await asyncio.to_thread(command.upgrade, Config(str(_ALEMBIC_INI)), "head")
argomenti = Namespace(db=db, replace=False, dry_run=False, fine=FINE.isoformat())
assert await seed_fiera._main_async(argomenti) == 0
con = sqlite3.connect(db)
try:
assert con.execute("SELECT COUNT(*) FROM users WHERE is_admin=1").fetchone()[0] == 1
assert con.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] == 3
assert con.execute("SELECT COUNT(*) FROM measurements").fetchone()[0] > 1000
finally:
con.close()
@@ -0,0 +1,338 @@
"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore.
Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come
fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno,
TMF la legge via GET {camera_url}/camera/calibration.
"""
import base64
import json
import httpx
import pytest
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask
from src.backend.models.orm.vision import VisionResult
from src.backend.services import vision_service
from src.backend.tests.conftest import auth_headers
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
_camera_task, _execute, _station_with_run, worker_calls,
)
CAMERA_URL = "http://10.0.0.9:8200"
CAL = {
"source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2,
"n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640,
"image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00",
}
GRAPH = {
"schema_version": 2, "name": "dxf-compare", "tools": [],
"meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}},
}
OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}]
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode()
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
@pytest.fixture
def w2(monkeypatch):
"""Finto worker W2: ricorda le chiamate e risponde col corpo del contratto."""
calls: list[dict] = []
async def fake(image_bytes, dxf_bytes, **kwargs):
calls.append({"dxf": dxf_bytes, **kwargs})
return {
"graph": GRAPH,
"files": {
"disegno.dxf": _b64(dxf_bytes),
"calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()),
},
"outputs": {"dxf.points_in_pct": 99.0},
"overlay": [], "outcome": "pass",
}
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
return calls
@pytest.fixture
def validate_ok(monkeypatch):
async def fake(graph, recipe_dir):
return {"ok": True, "errors": [], "outputs": OUTPUTS}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
@pytest.fixture
def acquisitore(monkeypatch):
"""Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile)."""
state: dict = {"cal": CAL, "urls": []}
class _Resp:
def __init__(self, code, body=None):
self.status_code, self._body, self.text = code, body, ""
def json(self):
return self._body
class _Client:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def get(self, url, params=None):
state["urls"].append((url, params))
if state["cal"] == "down":
raise httpx.ConnectError("giu'")
if state["cal"] is None:
return _Resp(404)
return _Resp(200, state["cal"])
monkeypatch.setattr(httpx, "AsyncClient", _Client)
return state
async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"):
station = Station(
code=code, name="Stazione C", active=True, created_by=user.id,
vision_worker_url=worker_url,
camera_url=CAMERA_URL if camera else None,
camera_source="camera:A1" if camera else None,
)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
return station
def _post(client, user, task, *, save=False, **data):
return client.post(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
headers=auth_headers(user),
files={
"image": ("p.png", an_image(), "image/png"),
"dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"),
},
data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}},
)
# --- lettura della calibrazione dall'acquisitore (C1) -------------------------
@pytest.mark.asyncio
async def test_fetch_calibration_ok(db_session, maker_user, acquisitore):
station = await _station(db_session, maker_user)
cal, reason = await vision_service.fetch_station_calibration(station)
assert (cal, reason) == (CAL, "ok")
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
@pytest.mark.asyncio
async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore):
station = await _station(db_session, maker_user)
acquisitore["cal"] = None
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
acquisitore["cal"] = "down"
assert await vision_service.fetch_station_calibration(station) == (None, "unreachable")
assert await vision_service.get_station_calibration(station) is None
acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
no_camera = await _station(db_session, maker_user, camera=False, code="ST-N")
assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera")
assert await vision_service.fetch_station_calibration(None) == (None, "no_camera")
# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare -----------------------
@pytest.mark.asyncio
async def test_dxf_compare_preview_writes_nothing(
client, db_session, maker_user, w2, acquisitore,
):
task, subtask = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100")
resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2,
min_points_pct=90, polarity="light")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None
assert "files" not in body["result"] and body["result"]["graph"] == GRAPH
# px_per_mm dall'acquisitore, parametri e worker della stazione
assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2
assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light"
assert w2[0]["worker_url"] == "http://10.0.0.7:8100"
await db_session.refresh(task)
assert task.vision_json is None and task.vision_recipe_dir is None
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova
assert not (settings.upload_path / "vision" / "recipes").exists()
@pytest.mark.asyncio
async def test_dxf_compare_save_stores_recipe_and_quote_once(
client, db_session, maker_user, w2, validate_ok,
):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["saved"] is True and body["outputs"] == OUTPUTS
assert "files" not in body["result"]
folder = settings.upload_path / body["recipe_dir"]
assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF"
assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0}
await db_session.refresh(task)
assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"]
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.task_id == task.id))).scalars().all()
assert len(quotes) == 1
q = quotes[0]
assert q.description == "Punti in tolleranza"
assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0)
# secondo salvataggio: la quota non si duplica
task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async
again = await _post(client, maker_user, task, save=True, px_per_mm=10)
assert again.status_code == 200
db_session.expire_all()
count = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None))
.where(RecipeSubtask.task_id == task_id))).scalars().all()
assert len(count) == 1
@pytest.mark.asyncio
async def test_dxf_compare_keeps_an_existing_vision_quote(
client, db_session, maker_user, w2, validate_ok,
):
task, subtask = await make_task(db_session, maker_user)
subtask.vision_output = "t1.diameter"
await db_session.commit()
resp = await _post(client, maker_user, task, save=True, px_per_mm=10)
assert resp.status_code == 200, resp.text
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
assert len(quotes) == 1 # niente quota in piu'
@pytest.mark.asyncio
async def test_dxf_compare_uncalibrated_station_is_422(
client, db_session, maker_user, w2, acquisitore,
):
task, _ = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user)
acquisitore["cal"] = None
resp = await _post(client, maker_user, task, station_id=station.id)
assert resp.status_code == 422
assert resp.json()["detail"] == ["stazione non calibrata"]
assert w2 == []
@pytest.mark.asyncio
async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message(
client, db_session, maker_user, w2, acquisitore,
):
task, _ = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user)
acquisitore["cal"] = "down"
resp = await _post(client, maker_user, task, station_id=station.id)
assert resp.status_code == 422
assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"]
@pytest.mark.asyncio
async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task)
assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"]
@pytest.mark.asyncio
async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, measurement_tec_user, task, px_per_mm=10)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_dxf_compare_worker_422_is_forwarded(
client, db_session, maker_user, monkeypatch,
):
from fastapi import HTTPException
async def refuse(*a, **k):
raise HTTPException(status_code=422, detail="pezzo non trovato")
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse)
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task, px_per_mm=10)
assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato"
# --- execute: calibrazione della stazione -------------------------------------
@pytest.mark.asyncio
async def test_execute_passes_station_calibration_and_snapshots_it(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5}
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot == CAL
@pytest.mark.asyncio
async def test_execute_without_calibration_is_unchanged(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione
await _execute(client, measurement_tec_user, task, run)
assert "calibration" not in worker_calls[0]
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot is None
@pytest.mark.asyncio
async def test_execute_station_without_camera_never_asks(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
_, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]