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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+31
-128
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"
|
||||
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') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</button>
|
||||
<!-- Reading counter badge -->
|
||||
<span x-show="readingCount > 0 && !active"
|
||||
x-transition
|
||||
class="text-[10px] font-mono text-slate-400 dark:text-slate-500"
|
||||
x-text="'(' + readingCount + ')'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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̄:</span>
|
||||
<span class="font-mono" x-text="capability.mean"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">σ:</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 %}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user