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 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 15:00:05 +01:00
parent e1f4ee73d0
commit bcd807e57d
14 changed files with 1567 additions and 242 deletions
+1
View File
@@ -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)
+93 -30
View File
@@ -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)
+30 -127
View File
@@ -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;
}
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);
}
},
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)) {
registerReading(value) {
this.lastReading = value;
this.readingCount++;
this.active = true;
// Dispatch event for other components
this.$dispatch('caliper-reading', {
value: value,
timestamp: new Date().toISOString()
});
}
}
} catch (err) {
console.error('Parse error:', err);
}
clearTimeout(this._flashTimeout);
this._flashTimeout = setTimeout(() => {
this.active = false;
}, 1500);
},
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);
}
};
}
+30 -1
View File
@@ -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;
}
};
}
+298
View File
@@ -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 =
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
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 =
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
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 =
'<p class="text-center text-steel py-8">' + _t('indicesNotAvailable') + '</p>';
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 });
}
+43 -57
View File
@@ -1,77 +1,63 @@
<!--
Caliper Status Indicator
Compact component for navbar or header
Shows connection status and last reading
Caliper Status Indicator - Passive HID Monitor
Shows USB caliper reading feedback without Web Serial.
Flashes green when a caliper reading is detected via burst timing.
-->
<div x-data="caliperConnection()"
x-init="$watch('connected', value => console.log('Caliper connected:', value))"
<div x-data="caliperStatus()"
class="inline-block">
<!-- Compact indicator button -->
<button @click="connected ? disconnect() : connect()"
:disabled="!isSupported"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all duration-200 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
:class="{
'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700': status === 'disconnected',
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800': status === 'connected',
'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800': status === 'connecting',
'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800': status === 'error'
}">
<!-- Passive indicator (no connect/disconnect button needed) -->
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all duration-300"
:class="active
? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700 shadow-md shadow-green-100 dark:shadow-green-900/30'
: 'bg-[var(--bg-card)] border-[var(--border-color)]'"
:title="readingCount > 0
? '{{ _('Letture calibro') }}: ' + readingCount
: '{{ _('Calibro USB') }}'">
<!-- Status dot with animation -->
<!-- Status dot -->
<span class="relative flex h-2.5 w-2.5">
<!-- Ping animation for active states -->
<span x-show="status === 'connected' || status === 'connecting'"
class="absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping"
:class="{
'bg-green-400': status === 'connected',
'bg-amber-400': status === 'connecting'
}"></span>
<!-- Ping animation when active -->
<span x-show="active"
x-transition
class="absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75 animate-ping"></span>
<!-- Static dot -->
<span class="relative inline-flex rounded-full h-2.5 w-2.5"
:class="{
'bg-green-500': status === 'connected',
'bg-slate-400': status === 'disconnected',
'bg-amber-400': status === 'connecting',
'bg-red-500': status === 'error'
}"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 transition-colors duration-300"
:class="active
? 'bg-green-500'
: readingCount > 0
? 'bg-green-400'
: 'bg-slate-400 dark:bg-slate-500'
"></span>
</span>
<!-- Caliper icon -->
<svg class="w-4 h-4 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
<svg class="w-4 h-4 transition-colors duration-300"
:class="active ? 'text-green-600 dark:text-green-400' : 'text-slate-500 dark:text-slate-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<!-- Status label -->
<span class="text-xs font-medium text-slate-700 dark:text-slate-300"
x-text="status === 'connected' ? '{{ _('Calibro') }}' :
status === 'connecting' ? '{{ _('Connessione...') }}' :
status === 'error' ? '{{ _('Errore calibro') }}' :
'{{ _('Calibro') }}'"></span>
<!-- Label -->
<span class="text-xs font-medium transition-colors duration-300"
:class="active ? 'text-green-700 dark:text-green-300' : 'text-slate-600 dark:text-slate-400'"
x-text="active ? '{{ _('Lettura calibro') }}' : '{{ _('Calibro USB') }}'"></span>
<!-- Last reading (when connected) -->
<span x-show="connected && lastReading !== null"
<!-- Last reading value (shows when active or when there's a recent reading) -->
<span x-show="lastReading !== null"
x-transition
class="font-mono text-xs font-semibold text-primary px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 rounded"
class="font-mono text-xs font-semibold px-1.5 py-0.5 rounded transition-colors duration-300"
:class="active
? 'text-green-700 dark:text-green-200 bg-green-100 dark:bg-green-800/40'
: 'text-primary bg-blue-50 dark:bg-blue-900/30'"
x-text="lastReading?.toFixed(3) + ' mm'"></span>
<!-- Not supported warning icon -->
<template x-if="!isSupported">
<div class="flex items-center gap-1.5" x-data="{ showTooltip: false }">
<svg @mouseenter="showTooltip = true"
@mouseleave="showTooltip = false"
class="w-4 h-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<!-- Tooltip -->
<div x-show="showTooltip"
<!-- Reading counter badge -->
<span x-show="readingCount > 0 && !active"
x-transition
class="absolute z-50 px-2 py-1 text-xs text-white bg-slate-900 rounded shadow-lg whitespace-nowrap -translate-y-8">
{{ _('Browser non supporta Web Serial API') }}
class="text-[10px] font-mono text-slate-400 dark:text-slate-500"
x-text="'(' + readingCount + ')'"></span>
</div>
</div>
</template>
</button>
</div>
+4 -10
View File
@@ -55,9 +55,8 @@
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col"
x-data="taskExecute()"
x-init="init()"
@numpad-confirm.window="handleMeasurement($event.detail.value)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"
@caliper-reading.window="handleCaliperReading($event.detail.value)">
@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;
+374
View File
@@ -0,0 +1,374 @@
{% extends "base.html" %}
{% block title %}{{ _("Statistiche SPC") }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<!-- Plotly.js CDN -->
<script src="https://cdn.plot.ly/plotly-2.32.0.min.js" charset="utf-8"></script>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-6"
x-data="spcDashboard()"
x-init="init()">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _("Statistiche SPC") }}</h1>
<p class="text-sm text-steel mt-1">{{ _("Analisi statistica di processo per le misurazioni") }}</p>
</div>
<!-- Filtri -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-4 mb-6 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Ricetta -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Ricetta") }}</label>
<select x-model="selectedRecipeId"
@change="onRecipeChange()"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">{{ _("Seleziona ricetta...") }}</option>
{% for r in recipes %}
<option value="{{ r.id }}">{{ r.code }} — {{ r.name }}</option>
{% endfor %}
</select>
</div>
<!-- Subtask -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
<select x-model="selectedSubtaskId"
:disabled="subtasks.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutti i punti") }}</option>
<template x-for="st in subtasks" :key="st.id">
<option :value="st.id" x-text="'#' + st.marker_number + ' — ' + st.description"></option>
</template>
</select>
</div>
<!-- Date range -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Dal") }}</label>
<input type="date" x-model="dateFrom"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Al") }}</label>
<input type="date" x-model="dateTo"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
</div>
</div>
<!-- Apply button -->
<div class="mt-4 flex justify-end">
<button @click="loadData()"
:disabled="!selectedRecipeId || loading"
class="px-5 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="loading">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
{{ _("Applica filtri") }}
</button>
</div>
</div>
<!-- Nessuna ricetta selezionata -->
<template x-if="!selectedRecipeId && !loading">
<div class="text-center py-16 text-steel">
<svg class="mx-auto h-16 w-16 text-steel-light mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<p class="text-lg font-medium">{{ _("Seleziona una ricetta per iniziare") }}</p>
<p class="text-sm mt-1">{{ _("Scegli una ricetta dal filtro e premi Applica filtri") }}</p>
</div>
</template>
<!-- Contenuto dashboard -->
<template x-if="hasData">
<div>
<!-- Row 1: Riepilogo + Capability -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Riepilogo Pass/Fail -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Riepilogo") }}</h2>
<template x-if="summary">
<div>
<div class="text-3xl font-bold font-mono text-[var(--text-primary)] mb-3" x-text="summary.total + ' misure'"></div>
<div class="grid grid-cols-3 gap-3">
<!-- Pass -->
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="summary.pass_rate + '%'"></div>
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("Pass") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.pass_count"></div>
</div>
<!-- Warning -->
<div class="text-center p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20">
<div class="text-2xl font-bold font-mono text-amber-600" x-text="summary.warning_rate + '%'"></div>
<div class="text-xs text-amber-700 dark:text-amber-400 mt-1">{{ _("Warning") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.warning_count"></div>
</div>
<!-- Fail -->
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
<div class="text-2xl font-bold font-mono text-red-600" x-text="summary.fail_rate + '%'"></div>
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("Fail") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.fail_count"></div>
</div>
</div>
</div>
</template>
</div>
<!-- Capability -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Indici di Capability") }}</h2>
<template x-if="capability && capability.cp != null">
<div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="text-center">
<div class="text-xs text-steel">Cp</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.cp)" x-text="capability.cp"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Cpk</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.cpk)" x-text="capability.cpk"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Pp</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.pp)" x-text="capability.pp"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Ppk</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.ppk)" x-text="capability.ppk"></div>
</div>
</div>
<!-- Gauge -->
<div id="cpk-gauge"></div>
<!-- Stats -->
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs text-steel">
<div>
<span class="font-medium">n:</span>
<span class="font-mono" x-text="capability.n"></span>
</div>
<div>
<span class="font-medium">x&#772;:</span>
<span class="font-mono" x-text="capability.mean"></span>
</div>
<div>
<span class="font-medium">&sigma;:</span>
<span class="font-mono" x-text="capability.std_dev"></span>
</div>
</div>
</div>
</template>
<template x-if="!capability || capability.cp == null">
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
</template>
</div>
</div>
<!-- Row 2: Carta di Controllo -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
<div id="control-chart" style="min-height: 350px;"></div>
</div>
<!-- Row 3: Istogramma -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
<div id="histogram-chart" style="min-height: 350px;"></div>
</div>
</div>
</template>
<!-- Error message -->
<template x-if="errorMessage">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300 text-sm">
<span x-text="errorMessage"></span>
</div>
</template>
</div>
{% endblock %}
{% block extra_js %}
<script>
// i18n bridge for spc-charts.js
window.SPC_I18N = {
noData: "{{ _('Nessun dato disponibile') }}",
inControl: "{{ _('In controllo') }}",
outOfControl: "{{ _('Fuori controllo') }}",
controlChart: "{{ _('Carta di Controllo') }}",
measureNum: "{{ _('Misura #') }}",
value: "{{ _('Valore') }}",
frequency: "{{ _('Frequenza') }}",
normalCurve: "{{ _('Curva normale') }}",
histogram: "{{ _('Istogramma') }}",
indicesNotAvailable: "{{ _('Indici non disponibili') }}",
};
</script>
<script src="{{ url_for('static', filename='js/spc-charts.js') }}"></script>
<script>
function spcDashboard() {
return {
// Filter state
selectedRecipeId: '',
selectedSubtaskId: '',
dateFrom: '',
dateTo: '',
subtasks: [],
// Data state
summary: null,
capability: null,
controlChart: null,
histogram: null,
// UI state
loading: false,
hasData: false,
errorMessage: '',
init() {
// Nothing to pre-load
},
async onRecipeChange() {
this.selectedSubtaskId = '';
this.subtasks = [];
this.hasData = false;
this.summary = null;
this.capability = null;
this.controlChart = null;
this.histogram = null;
if (!this.selectedRecipeId) return;
// Load subtasks for dropdown
try {
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
const data = await resp.json();
if (Array.isArray(data)) {
this.subtasks = data;
}
} catch (e) {
console.error('Error loading subtasks:', e);
}
},
async loadData() {
if (!this.selectedRecipeId) return;
this.loading = true;
this.errorMessage = '';
this.hasData = false;
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
try {
// Parallel fetch of all endpoints
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
this.selectedSubtaskId
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
this.selectedSubtaskId
? fetch('/statistics/api/control-chart?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
this.selectedSubtaskId
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
]);
// Summary (always available)
if (summaryResp.status === 'fulfilled' && !summaryResp.value?.error) {
this.summary = summaryResp.value;
} else {
this.errorMessage = (summaryResp.value?.detail) || '{{ _("Errore nel caricamento dei dati") }}';
this.loading = false;
return;
}
// Capability
if (capabilityResp.status === 'fulfilled' && capabilityResp.value && !capabilityResp.value?.error) {
this.capability = capabilityResp.value;
} else {
this.capability = null;
}
// Control chart
if (controlResp.status === 'fulfilled' && controlResp.value && !controlResp.value?.error) {
this.controlChart = controlResp.value;
} else {
this.controlChart = null;
}
// Histogram
if (histogramResp.status === 'fulfilled' && histogramResp.value && !histogramResp.value?.error) {
this.histogram = histogramResp.value;
} else {
this.histogram = null;
}
this.hasData = true;
// Render charts after DOM update
this.$nextTick(() => {
this.renderCharts();
});
} catch (e) {
console.error('Error loading SPC data:', e);
this.errorMessage = '{{ _("Errore di connessione al server") }}';
} finally {
this.loading = false;
}
},
renderCharts() {
// Control chart
if (this.controlChart && this.selectedSubtaskId) {
renderControlChart('control-chart', this.controlChart);
} else {
const el = document.getElementById('control-chart');
if (el) el.innerHTML = '<p class="text-center text-steel py-12">{{ _("Seleziona un punto di misura per la carta di controllo") }}</p>';
}
// Histogram
if (this.histogram && this.selectedSubtaskId) {
const tol = this.capability ? {
utl: this.capability.utl,
ltl: this.capability.ltl,
nominal: this.capability.nominal,
} : null;
renderHistogram('histogram-chart', this.histogram, tol);
} else {
const el = document.getElementById('histogram-chart');
if (el) el.innerHTML = '<p class="text-center text-steel py-12">{{ _("Seleziona un punto di misura per l\'istogramma") }}</p>';
}
// Capability gauge
if (this.capability && this.capability.cpk != null) {
renderCapabilityGauge('cpk-gauge', this.capability);
}
},
capabilityColor(value) {
if (value == null) return 'text-steel';
if (value >= 1.33) return 'text-emerald-600';
if (value >= 1.0) return 'text-amber-600';
return 'text-red-600';
},
};
}
</script>
{% endblock %}
+96 -7
View File
@@ -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"
+96 -7
View File
@@ -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"
+2
View File
@@ -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")
+236
View File
@@ -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
+261
View File
@@ -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,
)