From bcd807e57ddc309af5df9b9cce134388f8a5efde Mon Sep 17 00:00:00 2001 From: Adriano Date: Sat, 7 Feb 2026 15:00:05 +0100 Subject: [PATCH] feat: FASE 5b/6.1+6.2 - SPC Backend + Dashboard Metrologist (Plotly.js) Aggiunge servizio SPC con calcoli Cp/Cpk/Pp/Ppk, carta di controllo (UCL/LCL), istogramma con curva normale. Router FastAPI con 5 endpoint statistics, blueprint Flask con proxy AJAX, dashboard interattiva Alpine.js + Plotly.js con filtri per ricetta/subtask/date, riepilogo pass/fail, gauge Cpk e i18n IT/EN completo. Co-Authored-By: Claude Opus 4.6 --- client/blueprints/measure.py | 1 + client/blueprints/statistics.py | 123 ++++-- client/static/js/caliper.js | 159 ++------ client/static/js/numpad.js | 31 +- client/static/js/spc-charts.js | 298 ++++++++++++++ .../templates/components/caliper_status.html | 104 +++-- client/templates/measure/task_execute.html | 14 +- client/templates/statistics/.gitkeep | 0 client/templates/statistics/dashboard.html | 374 ++++++++++++++++++ .../translations/en/LC_MESSAGES/messages.po | 103 ++++- .../translations/it/LC_MESSAGES/messages.po | 103 ++++- server/main.py | 2 + server/routers/statistics.py | 236 +++++++++++ server/services/spc_service.py | 261 ++++++++++++ 14 files changed, 1567 insertions(+), 242 deletions(-) create mode 100644 client/static/js/spc-charts.js delete mode 100644 client/templates/statistics/.gitkeep create mode 100644 client/templates/statistics/dashboard.html create mode 100644 server/routers/statistics.py create mode 100644 server/services/spc_service.py diff --git a/client/blueprints/measure.py b/client/blueprints/measure.py index 8f65a82..76de545 100644 --- a/client/blueprints/measure.py +++ b/client/blueprints/measure.py @@ -259,6 +259,7 @@ def save_measurement(): "lot_number": data.get("lot_number", session.get("lot_number", "")), "serial_number": data.get("serial_number", session.get("serial_number", "")), "measured_by": session.get("user_id"), + "input_method": data.get("input_method", "manual"), } resp = api_client.post("/api/measurements", data=payload) diff --git a/client/blueprints/statistics.py b/client/blueprints/statistics.py index d00aece..4e639e0 100644 --- a/client/blueprints/statistics.py +++ b/client/blueprints/statistics.py @@ -1,44 +1,107 @@ -"""Metrologist blueprint - SPC statistics and dashboards.""" -from flask import Blueprint, render_template, session, redirect, url_for +"""Metrologist blueprint - SPC statistics dashboard and API proxies.""" +from flask import Blueprint, jsonify, render_template, request +from flask_babel import gettext as _ + +from blueprints.auth import login_required, role_required +from services.api_client import api_client statistics_bp = Blueprint("statistics", __name__) +# ============================================================================ +# PAGINE +# ============================================================================ + @statistics_bp.route("/dashboard") +@login_required +@role_required("Metrologist") def dashboard(): - """SPC dashboard overview.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("statistics/dashboard.html") + """SPC dashboard overview — loads recipes for filter dropdown.""" + resp = api_client.get("/api/recipes", params={"per_page": 100}) + if isinstance(resp, dict) and resp.get("error"): + recipes = [] + else: + recipes = resp.get("items", []) if isinstance(resp, dict) else [] + + return render_template("statistics/dashboard.html", recipes=recipes) -@statistics_bp.route("/control-chart") -def control_chart(): - """X-bar / R control chart.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("statistics/control_chart.html") +# ============================================================================ +# PROXY API AJAX → FastAPI +# ============================================================================ + +def _proxy_statistics(endpoint: str): + """Forward query params to a FastAPI statistics endpoint.""" + params = {} + for key in [ + "recipe_id", "version_id", "subtask_id", + "date_from", "date_to", "operator_id", + "lot_number", "serial_number", "n_bins", + ]: + val = request.args.get(key) + if val is not None and val != "": + params[key] = val + + resp = api_client.get(f"/api/statistics/{endpoint}", params=params) + + if isinstance(resp, dict) and resp.get("error"): + return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500) + + return jsonify(resp) -@statistics_bp.route("/histogram") -def histogram(): - """Histogram with normal curve.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("statistics/histogram.html") +@statistics_bp.route("/api/summary") +@login_required +@role_required("Metrologist") +def api_summary(): + """Proxy: summary pass/fail/warning.""" + return _proxy_statistics("summary") -@statistics_bp.route("/capability") -def capability(): - """Cp/Cpk/Pp/Ppk capability gauge.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("statistics/capability.html") +@statistics_bp.route("/api/capability") +@login_required +@role_required("Metrologist") +def api_capability(): + """Proxy: capability indices.""" + return _proxy_statistics("capability") -@statistics_bp.route("/trend") -def trend(): - """Temporal trends and period comparison.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("statistics/trend.html") +@statistics_bp.route("/api/control-chart") +@login_required +@role_required("Metrologist") +def api_control_chart(): + """Proxy: control chart data.""" + return _proxy_statistics("control-chart") + + +@statistics_bp.route("/api/histogram") +@login_required +@role_required("Metrologist") +def api_histogram(): + """Proxy: histogram data.""" + return _proxy_statistics("histogram") + + +@statistics_bp.route("/api/subtasks") +@login_required +@role_required("Metrologist") +def api_subtasks(): + """Proxy: get subtasks for recipe filter.""" + params = {} + recipe_id = request.args.get("recipe_id") + if recipe_id: + params["recipe_id"] = recipe_id + version_id = request.args.get("version_id") + if version_id: + params["version_id"] = version_id + + resp = api_client.get("/api/statistics/subtasks", params=params) + + if isinstance(resp, dict) and resp.get("error"): + return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500) + + # Response is a list + if isinstance(resp, list): + return jsonify(resp) + + return jsonify(resp) diff --git a/client/static/js/caliper.js b/client/static/js/caliper.js index 23fda57..d9cb623 100644 --- a/client/static/js/caliper.js +++ b/client/static/js/caliper.js @@ -1,142 +1,45 @@ /** - * Caliper Connection Manager - * Web Serial API integration for USB digital calipers - * Supports standard SPC/Digimatic protocol + * Caliper Status - Passive HID Monitor + * USB digital calipers act as keyboard emulators (HID), + * sending digits + Enter as keystrokes. This component + * monitors numpad-confirm events with inputMethod === 'usb_caliper' + * and provides visual feedback (no Web Serial needed). */ -function caliperConnection() { +function caliperStatus() { return { - connected: false, - status: 'disconnected', // disconnected, connecting, connected, error - lastReading: null, - port: null, - reader: null, - readController: null, + active: false, // Flash active (for animation) + lastReading: null, // Last value read from caliper + readingCount: 0, // Session reading counter + _flashTimeout: null, + _onConfirm: null, - get isSupported() { - return 'serial' in navigator; + init() { + // Listen for numpad confirmations with usb_caliper method + this._onConfirm = (e) => { + if (e.detail && e.detail.inputMethod === 'usb_caliper') { + this.registerReading(e.detail.value); + } + }; + window.addEventListener('numpad-confirm', this._onConfirm); }, - async connect() { - if (!this.isSupported) { - this.status = 'error'; - console.error('Web Serial API not supported'); - return; - } + registerReading(value) { + this.lastReading = value; + this.readingCount++; + this.active = true; - try { - this.status = 'connecting'; - - // Request port selection - this.port = await navigator.serial.requestPort(); - - // Open port with standard caliper settings - await this.port.open({ - baudRate: 9600, - dataBits: 8, - stopBits: 1, - parity: 'none', - flowControl: 'none' - }); - - this.connected = true; - this.status = 'connected'; - - // Start reading loop - this.readLoop(); - - } catch (err) { - this.status = 'error'; - this.connected = false; - console.error('Caliper connection error:', err); - } + clearTimeout(this._flashTimeout); + this._flashTimeout = setTimeout(() => { + this.active = false; + }, 1500); }, - async readLoop() { - if (!this.port || !this.port.readable) return; - - const reader = this.port.readable.getReader(); - this.reader = reader; - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) { - console.log('Reader closed'); - break; - } - this.parseData(value); - } - } catch (err) { - if (err.name !== 'NetworkError') { - console.error('Read error:', err); - } - } finally { - reader.releaseLock(); - } - }, - - parseData(data) { - try { - // Decode buffer to text - const text = new TextDecoder().decode(data); - - // Standard digital caliper protocol patterns - // Format: typically " XX.XXX mm\r\n" or similar - // Try multiple patterns for compatibility - - // Pattern 1: Standard with units - let match = text.match(/([+-]?\s*\d+\.?\d*)\s*mm/i); - - // Pattern 2: Just number with optional sign - if (!match) { - match = text.match(/([+-]?\s*\d+\.?\d*)/); - } - - if (match) { - // Clean whitespace and parse - const valueStr = match[1].replace(/\s+/g, ''); - const value = parseFloat(valueStr); - - if (!isNaN(value)) { - this.lastReading = value; - - // Dispatch event for other components - this.$dispatch('caliper-reading', { - value: value, - timestamp: new Date().toISOString() - }); - } - } - } catch (err) { - console.error('Parse error:', err); - } - }, - - async disconnect() { - try { - // Cancel reader - if (this.reader) { - await this.reader.cancel(); - this.reader = null; - } - - // Close port - if (this.port) { - await this.port.close(); - this.port = null; - } - } catch (err) { - console.error('Disconnect error:', err); - } finally { - this.connected = false; - this.status = 'disconnected'; - } - }, - - // Cleanup on component destroy destroy() { - this.disconnect(); + if (this._onConfirm) { + window.removeEventListener('numpad-confirm', this._onConfirm); + } + clearTimeout(this._flashTimeout); } }; } diff --git a/client/static/js/numpad.js b/client/static/js/numpad.js index a278c40..7a077f3 100644 --- a/client/static/js/numpad.js +++ b/client/static/js/numpad.js @@ -13,6 +13,10 @@ function numpad() { maxIntDigits: 6, // Maximum integer digits maxDecDigits: 6, // Maximum decimal digits + // HID burst detection (USB caliper vs manual typing) + _lastKeyTime: 0, // Timestamp of last keystroke + _burstCount: 0, // Consecutive fast keystrokes + /** * Get the display value with sign */ @@ -115,11 +119,16 @@ function numpad() { const val = this.numericValue; + // Determine input method: 3+ fast keystrokes = USB caliper burst + const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual'; + // Dispatch custom event for parent component to handle - this.$dispatch('numpad-confirm', { value: val }); + this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod }); // Reset after confirmation this.clearAll(); + this._burstCount = 0; + this._lastKeyTime = 0; }, /** @@ -157,11 +166,13 @@ function numpad() { // Number keys if (e.key >= '0' && e.key <= '9') { e.preventDefault(); + this._trackBurst(); this.addDigit(e.key); } // Decimal point (both . and ,) else if (e.key === '.' || e.key === ',') { e.preventDefault(); + this._trackBurst(); this.addDecimal(); } // Backspace @@ -184,6 +195,24 @@ function numpad() { e.preventDefault(); this.toggleSign(); } + }, + + /** + * Track keystroke timing for HID burst detection. + * USB calipers send digits in rapid succession (<80ms apart). + * Human typing is much slower (>100ms between keys). + */ + _trackBurst() { + const now = performance.now(); + const gap = now - this._lastKeyTime; + + if (this._lastKeyTime > 0 && gap < 80) { + this._burstCount++; + } else { + this._burstCount = 1; + } + + this._lastKeyTime = now; } }; } diff --git a/client/static/js/spc-charts.js b/client/static/js/spc-charts.js new file mode 100644 index 0000000..85c0063 --- /dev/null +++ b/client/static/js/spc-charts.js @@ -0,0 +1,298 @@ +/** + * SPC Charts — Plotly.js helper functions for Metrologist dashboard. + * + * Requires Plotly.js loaded via CDN before this script. + * Requires window.SPC_I18N object set in the template for translations. + * + * Brand colors: + * Primary: #2563EB + * Pass: #10b981 + * Warning: #f59e0b + * Fail: #ef4444 + */ + +// i18n helper — falls back to Italian defaults +const _t = (key) => (window.SPC_I18N && window.SPC_I18N[key]) || key; + +const SPC_COLORS = { + primary: '#2563EB', + primaryLight: '#3B82F6', + pass: '#10b981', + warning: '#f59e0b', + fail: '#ef4444', + steel: '#64748B', + steelLight: '#94A3B8', + nominal: '#8b5cf6', + ucl_lcl: '#ef4444', + uwl_lwl: '#f59e0b', + utl_ltl: '#dc2626', + bg: '#ffffff', + gridColor: '#e2e8f0', + textColor: '#334155', +}; + +const PLOTLY_LAYOUT_DEFAULTS = { + font: { family: 'Inter, system-ui, sans-serif', color: SPC_COLORS.textColor }, + paper_bgcolor: 'rgba(0,0,0,0)', + plot_bgcolor: 'rgba(0,0,0,0)', + margin: { t: 40, r: 30, b: 50, l: 60 }, + xaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false }, + yaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false }, +}; + +const PLOTLY_CONFIG = { + responsive: true, + displayModeBar: true, + modeBarButtonsToRemove: ['lasso2d', 'select2d'], + displaylogo: false, +}; + + +/** + * Render a control chart (X-bar) with tolerance and control limits. + * + * @param {string} containerId - DOM element ID for the chart. + * @param {Object} data - ControlChartData from API. + */ +function renderControlChart(containerId, data) { + if (!data || !data.values || data.values.length === 0) { + document.getElementById(containerId).innerHTML = + '

' + _t('noData') + '

'; + return; + } + + const n = data.values.length; + const xLabels = data.timestamps.map((t, i) => i + 1); + + // Separate in-control and out-of-control points + const oocSet = new Set(data.out_of_control || []); + const inControlX = [], inControlY = []; + const oocX = [], oocY = []; + + for (let i = 0; i < n; i++) { + if (oocSet.has(i)) { + oocX.push(xLabels[i]); + oocY.push(data.values[i]); + } else { + inControlX.push(xLabels[i]); + inControlY.push(data.values[i]); + } + } + + const traces = [ + // In-control points + { + x: inControlX, y: inControlY, + mode: 'markers', + type: 'scatter', + name: _t('inControl'), + marker: { color: SPC_COLORS.primary, size: 6 }, + }, + // Out-of-control points + { + x: oocX, y: oocY, + mode: 'markers', + type: 'scatter', + name: _t('outOfControl'), + marker: { color: SPC_COLORS.fail, size: 9, symbol: 'diamond' }, + }, + // Line connecting all points + { + x: xLabels, y: data.values, + mode: 'lines', + type: 'scatter', + name: '', + line: { color: SPC_COLORS.steelLight, width: 1 }, + showlegend: false, + }, + ]; + + // Helper to add horizontal line + const shapes = []; + function addLine(yVal, color, dash) { + if (yVal == null) return; + shapes.push({ + type: 'line', xref: 'paper', x0: 0, x1: 1, y0: yVal, y1: yVal, + line: { color, width: 1.5, dash }, + }); + } + + // Mean line + addLine(data.mean, SPC_COLORS.primary, 'solid'); + // UCL / LCL + addLine(data.ucl, SPC_COLORS.ucl_lcl, 'dash'); + addLine(data.lcl, SPC_COLORS.ucl_lcl, 'dash'); + // UTL / LTL + addLine(data.utl, SPC_COLORS.utl_ltl, 'dot'); + addLine(data.ltl, SPC_COLORS.utl_ltl, 'dot'); + // UWL / LWL + addLine(data.uwl, SPC_COLORS.uwl_lwl, 'dashdot'); + addLine(data.lwl, SPC_COLORS.uwl_lwl, 'dashdot'); + // Nominal + addLine(data.nominal, SPC_COLORS.nominal, 'longdash'); + + // Build annotations for limit labels + const annotations = []; + function addLabel(yVal, text, color) { + if (yVal == null) return; + annotations.push({ + x: 1, xref: 'paper', xanchor: 'left', + y: yVal, yanchor: 'middle', + text: text, + showarrow: false, + font: { size: 10, color }, + }); + } + addLabel(data.mean, 'X\u0304', SPC_COLORS.primary); + addLabel(data.ucl, 'UCL', SPC_COLORS.ucl_lcl); + addLabel(data.lcl, 'LCL', SPC_COLORS.ucl_lcl); + addLabel(data.utl, 'UTL', SPC_COLORS.utl_ltl); + addLabel(data.ltl, 'LTL', SPC_COLORS.utl_ltl); + addLabel(data.uwl, 'UWL', SPC_COLORS.uwl_lwl); + addLabel(data.lwl, 'LWL', SPC_COLORS.uwl_lwl); + addLabel(data.nominal, 'NOM', SPC_COLORS.nominal); + + const layout = { + ...PLOTLY_LAYOUT_DEFAULTS, + title: { text: _t('controlChart'), font: { size: 14 } }, + xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('measureNum') }, + yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('value') }, + shapes, + annotations, + showlegend: true, + legend: { orientation: 'h', y: -0.15 }, + margin: { ...PLOTLY_LAYOUT_DEFAULTS.margin, r: 50 }, + }; + + Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG); +} + + +/** + * Render histogram with normal curve overlay. + * + * @param {string} containerId - DOM element ID. + * @param {Object} data - HistogramData from API. + * @param {Object} [tol] - Optional tolerance limits {utl, ltl, nominal}. + */ +function renderHistogram(containerId, data, tol) { + if (!data || !data.bins || data.bins.length === 0 || data.n === 0) { + document.getElementById(containerId).innerHTML = + '

' + _t('noData') + '

'; + return; + } + + // Bin centers for bar chart + const binCenters = []; + const binWidth = data.bins[1] - data.bins[0]; + for (let i = 0; i < data.counts.length; i++) { + binCenters.push((data.bins[i] + data.bins[i + 1]) / 2); + } + + const traces = [ + // Histogram bars + { + x: binCenters, y: data.counts, + type: 'bar', + name: _t('frequency'), + marker: { color: SPC_COLORS.primaryLight, opacity: 0.7 }, + width: binWidth * 0.9, + }, + ]; + + // Normal curve overlay + if (data.normal_x && data.normal_x.length > 0) { + traces.push({ + x: data.normal_x, y: data.normal_y, + type: 'scatter', + mode: 'lines', + name: _t('normalCurve'), + line: { color: SPC_COLORS.fail, width: 2 }, + }); + } + + const shapes = []; + function addVertLine(xVal, color, dash) { + if (xVal == null) return; + shapes.push({ + type: 'line', yref: 'paper', y0: 0, y1: 1, x0: xVal, x1: xVal, + line: { color, width: 1.5, dash }, + }); + } + + if (tol) { + addVertLine(tol.utl, SPC_COLORS.utl_ltl, 'dot'); + addVertLine(tol.ltl, SPC_COLORS.utl_ltl, 'dot'); + addVertLine(tol.nominal, SPC_COLORS.nominal, 'longdash'); + } + + // Mean line + addVertLine(data.mean, SPC_COLORS.primary, 'dash'); + + const layout = { + ...PLOTLY_LAYOUT_DEFAULTS, + title: { text: _t('histogram'), font: { size: 14 } }, + xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('value') }, + yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('frequency') }, + shapes, + bargap: 0.05, + showlegend: true, + legend: { orientation: 'h', y: -0.15 }, + }; + + Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG); +} + + +/** + * Render a capability gauge (horizontal bar indicator for Cpk). + * + * @param {string} containerId - DOM element ID. + * @param {Object} data - CapabilityData from API. + */ +function renderCapabilityGauge(containerId, data) { + if (!data || data.cpk == null) { + document.getElementById(containerId).innerHTML = + '

' + _t('indicesNotAvailable') + '

'; + return; + } + + const cpk = data.cpk; + + // Color zones: <1.0 = fail, 1.0-1.33 = warning, >=1.33 = pass + let barColor = SPC_COLORS.fail; + if (cpk >= 1.33) barColor = SPC_COLORS.pass; + else if (cpk >= 1.0) barColor = SPC_COLORS.warning; + + const trace = { + type: 'indicator', + mode: 'gauge+number', + value: cpk, + title: { text: 'Cpk', font: { size: 14 } }, + number: { font: { family: 'JetBrains Mono, monospace', size: 28 } }, + gauge: { + axis: { range: [0, 2.5], tickwidth: 1 }, + bar: { color: barColor, thickness: 0.6 }, + bgcolor: '#f1f5f9', + borderwidth: 0, + steps: [ + { range: [0, 1.0], color: '#fecaca' }, + { range: [1.0, 1.33], color: '#fef3c7' }, + { range: [1.33, 2.5], color: '#d1fae5' }, + ], + threshold: { + line: { color: SPC_COLORS.primary, width: 3 }, + thickness: 0.75, + value: cpk, + }, + }, + }; + + const layout = { + ...PLOTLY_LAYOUT_DEFAULTS, + height: 200, + margin: { t: 30, r: 20, b: 10, l: 20 }, + }; + + Plotly.newPlot(containerId, [trace], layout, { ...PLOTLY_CONFIG, displayModeBar: false }); +} diff --git a/client/templates/components/caliper_status.html b/client/templates/components/caliper_status.html index 2248b0c..65721ad 100644 --- a/client/templates/components/caliper_status.html +++ b/client/templates/components/caliper_status.html @@ -1,77 +1,63 @@ -
- - + + +
diff --git a/client/templates/measure/task_execute.html b/client/templates/measure/task_execute.html index 200d7df..5a2f7ed 100644 --- a/client/templates/measure/task_execute.html +++ b/client/templates/measure/task_execute.html @@ -55,9 +55,8 @@
+ @numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)" + @marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"> {# ================================================================ HEADER — Task info + traceability @@ -628,7 +627,7 @@ function taskExecute() { }, // ---- Handle numpad confirm ---- - async handleMeasurement(value) { + async handleMeasurement(value, inputMethod) { if (!this.currentSubtask || this.saving) return; this.currentValue = value; @@ -657,6 +656,7 @@ function taskExecute() { deviation: dev, lot_number: this.lotNumber, serial_number: this.serialNumber, + input_method: inputMethod || 'manual', }), }); @@ -732,12 +732,6 @@ function taskExecute() { } }, - // ---- Caliper reading ---- - handleCaliperReading(value) { - // Auto-fill value from USB caliper - this.currentValue = value; - }, - // ---- Go to summary ---- goToSummary() { const recipeId = this.task.version_id || this.task.recipe_id || 0; diff --git a/client/templates/statistics/.gitkeep b/client/templates/statistics/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/client/templates/statistics/dashboard.html b/client/templates/statistics/dashboard.html new file mode 100644 index 0000000..3a9d7d2 --- /dev/null +++ b/client/templates/statistics/dashboard.html @@ -0,0 +1,374 @@ +{% extends "base.html" %} + +{% block title %}{{ _("Statistiche SPC") }} — TieMeasureFlow{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ + +
+

{{ _("Statistiche SPC") }}

+

{{ _("Analisi statistica di processo per le misurazioni") }}

+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + +
+{% endblock %} + +{% block extra_js %} + + + +{% endblock %} diff --git a/client/translations/en/LC_MESSAGES/messages.po b/client/translations/en/LC_MESSAGES/messages.po index d9cfe66..e18bf80 100644 --- a/client/translations/en/LC_MESSAGES/messages.po +++ b/client/translations/en/LC_MESSAGES/messages.po @@ -489,15 +489,15 @@ msgstr "WARNING" msgid "NON CONFORME" msgstr "NON-CONFORMING" -# Caliper Status -msgid "Connessione..." -msgstr "Connecting..." +# Caliper Status (Passive HID Monitor) +msgid "Calibro USB" +msgstr "USB Caliper" -msgid "Errore calibro" -msgstr "Caliper Error" +msgid "Lettura calibro" +msgstr "Caliper reading" -msgid "Browser non supporta Web Serial API" -msgstr "Browser does not support Web Serial API" +msgid "Letture calibro" +msgstr "Caliper readings" # Recipe Selection Additional msgid "Errore di connessione" @@ -949,3 +949,92 @@ msgstr "Error loading recipe: %(error)s" msgid "Errore nel caricamento delle versioni: %(error)s" msgstr "Error loading versions: %(error)s" + +# SPC Statistics Dashboard +msgid "Statistiche SPC" +msgstr "SPC Statistics" + +msgid "Analisi statistica di processo per le misurazioni" +msgstr "Statistical process analysis for measurements" + +msgid "Seleziona ricetta..." +msgstr "Select recipe..." + +msgid "Punto di misura" +msgstr "Measurement point" + +msgid "Tutti i punti" +msgstr "All points" + +msgid "Dal" +msgstr "From" + +msgid "Al" +msgstr "To" + +msgid "Applica filtri" +msgstr "Apply filters" + +msgid "Seleziona una ricetta per iniziare" +msgstr "Select a recipe to start" + +msgid "Scegli una ricetta dal filtro e premi Applica filtri" +msgstr "Choose a recipe from the filter and press Apply filters" + +msgid "Riepilogo" +msgstr "Summary" + +msgid "Pass" +msgstr "Pass" + +msgid "Warning" +msgstr "Warning" + +msgid "Fail" +msgstr "Fail" + +msgid "Indici di Capability" +msgstr "Capability Indices" + +msgid "Seleziona un punto di misura per calcolare gli indici" +msgstr "Select a measurement point to calculate indices" + +msgid "Carta di Controllo" +msgstr "Control Chart" + +msgid "Istogramma" +msgstr "Histogram" + +msgid "Seleziona un punto di misura per la carta di controllo" +msgstr "Select a measurement point for the control chart" + +msgid "Seleziona un punto di misura per l'istogramma" +msgstr "Select a measurement point for the histogram" + +msgid "Errore nel caricamento dei dati" +msgstr "Error loading data" + +# SPC Charts JS i18n +msgid "Nessun dato disponibile" +msgstr "No data available" + +msgid "In controllo" +msgstr "In control" + +msgid "Fuori controllo" +msgstr "Out of control" + +msgid "Misura #" +msgstr "Measurement #" + +msgid "Valore" +msgstr "Value" + +msgid "Frequenza" +msgstr "Frequency" + +msgid "Curva normale" +msgstr "Normal curve" + +msgid "Indici non disponibili" +msgstr "Indices not available" diff --git a/client/translations/it/LC_MESSAGES/messages.po b/client/translations/it/LC_MESSAGES/messages.po index d1343d6..a56a373 100644 --- a/client/translations/it/LC_MESSAGES/messages.po +++ b/client/translations/it/LC_MESSAGES/messages.po @@ -489,15 +489,15 @@ msgstr "ATTENZIONE" msgid "NON CONFORME" msgstr "NON CONFORME" -# Caliper Status -msgid "Connessione..." -msgstr "Connessione..." +# Caliper Status (Passive HID Monitor) +msgid "Calibro USB" +msgstr "Calibro USB" -msgid "Errore calibro" -msgstr "Errore calibro" +msgid "Lettura calibro" +msgstr "Lettura calibro" -msgid "Browser non supporta Web Serial API" -msgstr "Browser non supporta Web Serial API" +msgid "Letture calibro" +msgstr "Letture calibro" # Recipe Selection Additional msgid "Errore di connessione" @@ -949,3 +949,92 @@ msgstr "Errore nel caricamento della ricetta: %(error)s" msgid "Errore nel caricamento delle versioni: %(error)s" msgstr "Errore nel caricamento delle versioni: %(error)s" + +# SPC Statistics Dashboard +msgid "Statistiche SPC" +msgstr "Statistiche SPC" + +msgid "Analisi statistica di processo per le misurazioni" +msgstr "Analisi statistica di processo per le misurazioni" + +msgid "Seleziona ricetta..." +msgstr "Seleziona ricetta..." + +msgid "Punto di misura" +msgstr "Punto di misura" + +msgid "Tutti i punti" +msgstr "Tutti i punti" + +msgid "Dal" +msgstr "Dal" + +msgid "Al" +msgstr "Al" + +msgid "Applica filtri" +msgstr "Applica filtri" + +msgid "Seleziona una ricetta per iniziare" +msgstr "Seleziona una ricetta per iniziare" + +msgid "Scegli una ricetta dal filtro e premi Applica filtri" +msgstr "Scegli una ricetta dal filtro e premi Applica filtri" + +msgid "Riepilogo" +msgstr "Riepilogo" + +msgid "Pass" +msgstr "Pass" + +msgid "Warning" +msgstr "Warning" + +msgid "Fail" +msgstr "Fail" + +msgid "Indici di Capability" +msgstr "Indici di Capability" + +msgid "Seleziona un punto di misura per calcolare gli indici" +msgstr "Seleziona un punto di misura per calcolare gli indici" + +msgid "Carta di Controllo" +msgstr "Carta di Controllo" + +msgid "Istogramma" +msgstr "Istogramma" + +msgid "Seleziona un punto di misura per la carta di controllo" +msgstr "Seleziona un punto di misura per la carta di controllo" + +msgid "Seleziona un punto di misura per l'istogramma" +msgstr "Seleziona un punto di misura per l'istogramma" + +msgid "Errore nel caricamento dei dati" +msgstr "Errore nel caricamento dei dati" + +# SPC Charts JS i18n +msgid "Nessun dato disponibile" +msgstr "Nessun dato disponibile" + +msgid "In controllo" +msgstr "In controllo" + +msgid "Fuori controllo" +msgstr "Fuori controllo" + +msgid "Misura #" +msgstr "Misura #" + +msgid "Valore" +msgstr "Valore" + +msgid "Frequenza" +msgstr "Frequenza" + +msgid "Curva normale" +msgstr "Curva normale" + +msgid "Indici non disponibili" +msgstr "Indici non disponibili" diff --git a/server/main.py b/server/main.py index afa2b92..a87352a 100644 --- a/server/main.py +++ b/server/main.py @@ -15,6 +15,7 @@ from routers.tasks import router as tasks_router from routers.measurements import router as measurements_router from routers.files import router as files_router from routers.settings import router as settings_router +from routers.statistics import router as statistics_router @asynccontextmanager @@ -58,6 +59,7 @@ app.include_router(tasks_router) app.include_router(measurements_router) app.include_router(files_router) app.include_router(settings_router) +app.include_router(statistics_router) @app.get("/api/health") diff --git a/server/routers/statistics.py b/server/routers/statistics.py new file mode 100644 index 0000000..7c8df77 --- /dev/null +++ b/server/routers/statistics.py @@ -0,0 +1,236 @@ +"""Statistics router - SPC endpoints for Metrologist dashboard.""" +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from middleware.api_key import require_metrologist +from models.measurement import Measurement +from models.recipe import RecipeVersion +from models.task import RecipeSubtask, RecipeTask +from models.user import User +from schemas.statistics import ( + CapabilityData, + ControlChartData, + HistogramData, + SummaryData, +) +from services.spc_service import ( + compute_capability, + compute_control_chart, + compute_histogram, + compute_summary, +) + +router = APIRouter(prefix="/api/statistics", tags=["statistics"]) + + +async def _query_measurements( + db: AsyncSession, + recipe_id: int, + version_id: int | None = None, + subtask_id: int | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + operator_id: int | None = None, + lot_number: str | None = None, + serial_number: str | None = None, +) -> list[Measurement]: + """Query measurements with common filters. + + Returns list of Measurement ORM objects ordered by measured_at. + """ + filters = [] + + # recipe_id filter via RecipeVersion subquery + if version_id is not None: + filters.append(Measurement.version_id == version_id) + else: + version_ids = select(RecipeVersion.id).where( + RecipeVersion.recipe_id == recipe_id + ) + filters.append(Measurement.version_id.in_(version_ids)) + + if subtask_id is not None: + filters.append(Measurement.subtask_id == subtask_id) + if date_from is not None: + filters.append(Measurement.measured_at >= date_from) + if date_to is not None: + filters.append(Measurement.measured_at <= date_to) + if operator_id is not None: + filters.append(Measurement.measured_by == operator_id) + if lot_number is not None: + filters.append(Measurement.lot_number == lot_number) + if serial_number is not None: + filters.append(Measurement.serial_number == serial_number) + + query = ( + select(Measurement) + .where(and_(*filters) if filters else True) + .order_by(Measurement.measured_at.asc()) + ) + result = await db.execute(query) + return list(result.scalars().all()) + + +async def _get_subtask_tolerances( + db: AsyncSession, + subtask_id: int, +) -> dict: + """Get tolerance limits for a specific subtask.""" + result = await db.execute( + select(RecipeSubtask).where(RecipeSubtask.id == subtask_id) + ) + subtask = result.scalar_one_or_none() + if subtask is None: + return {"utl": None, "uwl": None, "lwl": None, "ltl": None, "nominal": None} + return { + "utl": float(subtask.utl) if subtask.utl is not None else None, + "uwl": float(subtask.uwl) if subtask.uwl is not None else None, + "lwl": float(subtask.lwl) if subtask.lwl is not None else None, + "ltl": float(subtask.ltl) if subtask.ltl is not None else None, + "nominal": float(subtask.nominal) if subtask.nominal is not None else None, + } + + +@router.get("/summary", response_model=SummaryData) +async def get_summary( + recipe_id: int = Query(...), + version_id: int | None = Query(None), + subtask_id: int | None = Query(None), + date_from: datetime | None = Query(None), + date_to: datetime | None = Query(None), + operator_id: int | None = Query(None), + lot_number: str | None = Query(None), + serial_number: str | None = Query(None), + user: User = Depends(require_metrologist), + db: AsyncSession = Depends(get_db), +) -> SummaryData: + """Get pass/fail/warning summary for filtered measurements.""" + measurements = await _query_measurements( + db, recipe_id, version_id, subtask_id, + date_from, date_to, operator_id, lot_number, serial_number, + ) + pass_fail_values = [m.pass_fail for m in measurements] + return compute_summary(pass_fail_values) + + +@router.get("/capability", response_model=CapabilityData) +async def get_capability( + recipe_id: int = Query(...), + subtask_id: int = Query(...), + version_id: int | None = Query(None), + date_from: datetime | None = Query(None), + date_to: datetime | None = Query(None), + operator_id: int | None = Query(None), + lot_number: str | None = Query(None), + serial_number: str | None = Query(None), + user: User = Depends(require_metrologist), + db: AsyncSession = Depends(get_db), +) -> CapabilityData: + """Get capability indices (Cp/Cpk/Pp/Ppk) for a specific subtask.""" + measurements = await _query_measurements( + db, recipe_id, version_id, subtask_id, + date_from, date_to, operator_id, lot_number, serial_number, + ) + values = [float(m.value) for m in measurements] + tol = await _get_subtask_tolerances(db, subtask_id) + return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"]) + + +@router.get("/control-chart", response_model=ControlChartData) +async def get_control_chart( + recipe_id: int = Query(...), + subtask_id: int = Query(...), + version_id: int | None = Query(None), + date_from: datetime | None = Query(None), + date_to: datetime | None = Query(None), + operator_id: int | None = Query(None), + lot_number: str | None = Query(None), + serial_number: str | None = Query(None), + user: User = Depends(require_metrologist), + db: AsyncSession = Depends(get_db), +) -> ControlChartData: + """Get control chart data with UCL/LCL and OOC detection.""" + measurements = await _query_measurements( + db, recipe_id, version_id, subtask_id, + date_from, date_to, operator_id, lot_number, serial_number, + ) + values = [float(m.value) for m in measurements] + timestamps = [m.measured_at for m in measurements] + tol = await _get_subtask_tolerances(db, subtask_id) + return compute_control_chart( + values, timestamps, + tol["utl"], tol["uwl"], tol["lwl"], tol["ltl"], tol["nominal"], + ) + + +@router.get("/histogram", response_model=HistogramData) +async def get_histogram( + recipe_id: int = Query(...), + subtask_id: int = Query(...), + version_id: int | None = Query(None), + date_from: datetime | None = Query(None), + date_to: datetime | None = Query(None), + operator_id: int | None = Query(None), + lot_number: str | None = Query(None), + serial_number: str | None = Query(None), + n_bins: int = Query(20, ge=5, le=100), + user: User = Depends(require_metrologist), + db: AsyncSession = Depends(get_db), +) -> HistogramData: + """Get histogram data with normal curve overlay.""" + measurements = await _query_measurements( + db, recipe_id, version_id, subtask_id, + date_from, date_to, operator_id, lot_number, serial_number, + ) + values = [float(m.value) for m in measurements] + return compute_histogram(values, n_bins) + + +@router.get("/subtasks") +async def get_recipe_subtasks( + recipe_id: int = Query(...), + version_id: int | None = Query(None), + user: User = Depends(require_metrologist), + db: AsyncSession = Depends(get_db), +) -> list[dict]: + """Get subtasks for a recipe (for filter dropdown). + + Returns subtask id, marker_number, description, and parent task title. + """ + # Get version_id: use provided or find current version + if version_id is None: + ver_result = await db.execute( + select(RecipeVersion.id).where( + RecipeVersion.recipe_id == recipe_id, + RecipeVersion.is_current == True, + ) + ) + version_id = ver_result.scalar_one_or_none() + if version_id is None: + return [] + + # Get tasks and subtasks for this version + tasks_result = await db.execute( + select(RecipeTask).where(RecipeTask.version_id == version_id) + .order_by(RecipeTask.order_index) + ) + tasks = tasks_result.scalars().all() + + subtasks_list = [] + for task in tasks: + for st in sorted(task.subtasks, key=lambda s: s.marker_number): + subtasks_list.append({ + "id": st.id, + "marker_number": st.marker_number, + "description": st.description, + "task_title": task.title, + "nominal": float(st.nominal) if st.nominal is not None else None, + "utl": float(st.utl) if st.utl is not None else None, + "ltl": float(st.ltl) if st.ltl is not None else None, + }) + + return subtasks_list diff --git a/server/services/spc_service.py b/server/services/spc_service.py new file mode 100644 index 0000000..92084a4 --- /dev/null +++ b/server/services/spc_service.py @@ -0,0 +1,261 @@ +"""SPC (Statistical Process Control) computation service. + +Pure functions — no DB dependencies. Receives lists of floats and tolerance limits, +returns structured data matching schemas in schemas/statistics.py. + +Uses only Python stdlib (math, statistics). No numpy/scipy needed. +""" +import math +import statistics as stats +from datetime import datetime + +from schemas.statistics import ( + CapabilityData, + ControlChartData, + HistogramData, + SummaryData, +) + + +def compute_summary( + pass_fail_values: list[str], +) -> SummaryData: + """Compute pass/fail/warning summary from a list of pass_fail strings. + + Args: + pass_fail_values: List of "pass", "warning", or "fail" strings. + + Returns: + SummaryData with counts and rates. + """ + total = len(pass_fail_values) + if total == 0: + return SummaryData( + total=0, + pass_count=0, + warning_count=0, + fail_count=0, + pass_rate=0.0, + warning_rate=0.0, + fail_rate=0.0, + ) + + pass_count = pass_fail_values.count("pass") + warning_count = pass_fail_values.count("warning") + fail_count = pass_fail_values.count("fail") + + return SummaryData( + total=total, + pass_count=pass_count, + warning_count=warning_count, + fail_count=fail_count, + pass_rate=round(pass_count / total * 100, 2), + warning_rate=round(warning_count / total * 100, 2), + fail_rate=round(fail_count / total * 100, 2), + ) + + +def compute_capability( + values: list[float], + utl: float | None, + ltl: float | None, + nominal: float | None, +) -> CapabilityData: + """Compute capability indices Cp, Cpk, Pp, Ppk. + + - Cp = (UTL - LTL) / (6 * sigma_within) + - Cpk = min((UTL - mean) / (3 * sigma), (mean - LTL) / (3 * sigma)) + - Pp/Ppk: same formulas using population std dev (same data, no subgrouping) + + Args: + values: Measured values. + utl: Upper Tolerance Limit (None if not defined). + ltl: Lower Tolerance Limit (None if not defined). + nominal: Nominal value (None if not defined). + + Returns: + CapabilityData with indices and statistics. + """ + n = len(values) + if n < 2: + return CapabilityData( + cp=None, cpk=None, pp=None, ppk=None, + mean=values[0] if n == 1 else 0.0, + std_dev=0.0, n=n, + utl=utl, ltl=ltl, nominal=nominal, + ) + + mean = stats.mean(values) + # Population std dev for Pp/Ppk + std_dev_pop = stats.pstdev(values) + # Sample std dev for Cp/Cpk + std_dev_sample = stats.stdev(values) + + cp = cpk = pp = ppk = None + + if utl is not None and ltl is not None and std_dev_sample > 0: + cp = round((utl - ltl) / (6 * std_dev_sample), 4) + + if std_dev_sample > 0: + cpk_values = [] + if utl is not None: + cpk_values.append((utl - mean) / (3 * std_dev_sample)) + if ltl is not None: + cpk_values.append((mean - ltl) / (3 * std_dev_sample)) + if cpk_values: + cpk = round(min(cpk_values), 4) + + if utl is not None and ltl is not None and std_dev_pop > 0: + pp = round((utl - ltl) / (6 * std_dev_pop), 4) + + if std_dev_pop > 0: + ppk_values = [] + if utl is not None: + ppk_values.append((utl - mean) / (3 * std_dev_pop)) + if ltl is not None: + ppk_values.append((mean - ltl) / (3 * std_dev_pop)) + if ppk_values: + ppk = round(min(ppk_values), 4) + + return CapabilityData( + cp=cp, cpk=cpk, pp=pp, ppk=ppk, + mean=round(mean, 6), + std_dev=round(std_dev_sample, 6), + n=n, + utl=utl, ltl=ltl, nominal=nominal, + ) + + +def compute_control_chart( + values: list[float], + timestamps: list[datetime], + utl: float | None, + uwl: float | None, + lwl: float | None, + ltl: float | None, + nominal: float | None, +) -> ControlChartData: + """Compute control chart data with UCL/LCL and out-of-control detection. + + UCL = mean + 3*sigma + LCL = mean - 3*sigma + Out-of-control: points outside UCL/LCL. + + Args: + values: Measured values in chronological order. + timestamps: Corresponding timestamps. + utl/uwl/lwl/ltl: Tolerance/warning limits. + nominal: Nominal value. + + Returns: + ControlChartData with values, limits, and OOC indices. + """ + n = len(values) + if n == 0: + return ControlChartData( + values=[], timestamps=[], mean=0.0, ucl=0.0, lcl=0.0, + utl=utl, uwl=uwl, lwl=lwl, ltl=ltl, nominal=nominal, + out_of_control=[], + ) + + mean = stats.mean(values) + + if n >= 2: + sigma = stats.stdev(values) + else: + sigma = 0.0 + + ucl = mean + 3 * sigma + lcl = mean - 3 * sigma + + # Detect out-of-control points (outside UCL/LCL) + out_of_control = [] + for i, v in enumerate(values): + if v > ucl or v < lcl: + out_of_control.append(i) + + return ControlChartData( + values=values, + timestamps=timestamps, + mean=round(mean, 6), + ucl=round(ucl, 6), + lcl=round(lcl, 6), + utl=utl, + uwl=uwl, + lwl=lwl, + ltl=ltl, + nominal=nominal, + out_of_control=out_of_control, + ) + + +def compute_histogram( + values: list[float], + n_bins: int = 20, +) -> HistogramData: + """Compute histogram bin data and normal curve overlay. + + Args: + values: Measured values. + n_bins: Number of histogram bins (default 20). + + Returns: + HistogramData with bins, counts, and normal curve points. + """ + n = len(values) + if n == 0: + return HistogramData( + bins=[], counts=[], normal_x=[], normal_y=[], + mean=0.0, std_dev=0.0, n=0, + ) + + mean = stats.mean(values) + std_dev = stats.pstdev(values) if n >= 2 else 0.0 + + min_val = min(values) + max_val = max(values) + + # Avoid zero-width range + if max_val == min_val: + max_val = min_val + 1.0 + + bin_width = (max_val - min_val) / n_bins + bins = [round(min_val + i * bin_width, 6) for i in range(n_bins + 1)] + + # Count values per bin + counts = [0] * n_bins + for v in values: + idx = int((v - min_val) / bin_width) + if idx >= n_bins: + idx = n_bins - 1 + counts[idx] += 1 + + # Normal curve overlay (100 points) + normal_x: list[float] = [] + normal_y: list[float] = [] + if std_dev > 0: + n_curve_points = 100 + x_min = mean - 4 * std_dev + x_max = mean + 4 * std_dev + x_step = (x_max - x_min) / (n_curve_points - 1) + + for i in range(n_curve_points): + x = x_min + i * x_step + # Normal PDF: (1 / (sigma * sqrt(2*pi))) * exp(-0.5 * ((x-mu)/sigma)^2) + y = (1.0 / (std_dev * math.sqrt(2 * math.pi))) * math.exp( + -0.5 * ((x - mean) / std_dev) ** 2 + ) + # Scale to match histogram: y * n * bin_width + y_scaled = y * n * bin_width + normal_x.append(round(x, 6)) + normal_y.append(round(y_scaled, 4)) + + return HistogramData( + bins=bins, + counts=counts, + normal_x=normal_x, + normal_y=normal_y, + mean=round(mean, 6), + std_dev=round(std_dev, 6), + n=n, + )