Files
TieMeasureFlow/scripts/seed_fiera.py

459 lines
22 KiB
Python

"""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())