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 @@ -{{ _("Analisi statistica di processo per le misurazioni") }}
+{{ _("Seleziona una ricetta per iniziare") }}
+{{ _("Scegli una ricetta dal filtro e premi Applica filtri") }}
+{{ _("Seleziona un punto di misura per calcolare gli indici") }}
+ +