@holoscript/plugin-manufacturing-qc 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/spc.ts ADDED
@@ -0,0 +1,728 @@
1
+ /**
2
+ * Statistical Process Control (SPC) solver for @holoscript/plugin-manufacturing-qc
3
+ *
4
+ * Provides:
5
+ * - Shewhart control charts: X̅-R, X̅-s, p-chart, np-chart, c-chart, u-chart
6
+ * - Process capability indices: Cp, Cpk, Pp, Ppk, Cpm (taguchi)
7
+ * - Western Electric / Nelson rules for out-of-control detection
8
+ * - CAEL-ready receipt builder
9
+ *
10
+ * All calculations are deterministic and dependency-free.
11
+ * Reference: Montgomery, "Introduction to Statistical Quality Control", 8th ed.
12
+ *
13
+ * @version 1.0.0
14
+ */
15
+
16
+ import {
17
+ DOMAIN_SIMULATION_RECEIPT_SCHEMA,
18
+ buildDomainSimulationReceipt,
19
+ type DomainSimulationReceipt,
20
+ } from '@holoscript/core';
21
+
22
+ // ─── Types ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ export type ChartType = 'xbar_r' | 'xbar_s' | 'p' | 'np' | 'c' | 'u';
25
+
26
+ export interface Subgroup {
27
+ /** Subgroup index (1-based) */
28
+ index: number;
29
+ /** Raw measurements within the subgroup */
30
+ values: number[];
31
+ /** Sample size (for attribute charts, total inspected) */
32
+ n?: number;
33
+ /** Number of nonconforming units or defects (attribute charts only) */
34
+ defects?: number;
35
+ }
36
+
37
+ export interface ControlLimits {
38
+ centerLine: number;
39
+ ucl: number;
40
+ lcl: number;
41
+ /** Lower bound is clipped to 0 for attribute charts where negative is impossible */
42
+ lclFloor?: number;
43
+ }
44
+
45
+ export interface SubgroupStat {
46
+ index: number;
47
+ n: number;
48
+ mean?: number;
49
+ range?: number;
50
+ stdDev?: number;
51
+ proportion?: number;
52
+ rate?: number;
53
+ outOfControl: boolean;
54
+ violatedRules: string[];
55
+ }
56
+
57
+ export interface SPCChartResult {
58
+ chartType: ChartType;
59
+ subgroupCount: number;
60
+ totalObservations: number;
61
+ primaryChart: ControlLimits; // X̅ or p or c/u
62
+ secondaryChart?: ControlLimits; // R or s (only for variables charts)
63
+ subgroupStats: SubgroupStat[];
64
+ outOfControlCount: number;
65
+ processInControl: boolean;
66
+ }
67
+
68
+ export interface ProcessCapability {
69
+ /** Spec limits */
70
+ lsl: number;
71
+ usl: number;
72
+ target?: number;
73
+ /** Process parameters (estimated from data) */
74
+ processMean: number;
75
+ processStdDev: number;
76
+ /** Potential capability (short-term, uses σ̂ from within-subgroup) */
77
+ Cp: number;
78
+ /** Actual capability (uses σ̂ from within-subgroup, accounts for centering) */
79
+ Cpk: number;
80
+ CpkLower: number;
81
+ CpkUpper: number;
82
+ /** Performance indices (long-term, uses overall σ) */
83
+ Pp: number;
84
+ Ppk: number;
85
+ /** Taguchi index (penalises deviation from target) */
86
+ Cpm?: number;
87
+ /** Estimated fraction nonconforming (normal approximation) */
88
+ ppmAboveUSL: number;
89
+ ppmBelowLSL: number;
90
+ ppmTotal: number;
91
+ /** Pass if Cpk >= 1.33 (four-sigma quality level) */
92
+ capable: boolean;
93
+ }
94
+
95
+ export interface SPCReceiptOptions {
96
+ runId?: string;
97
+ createdAt?: string;
98
+ }
99
+
100
+ export interface SPCReceipt {
101
+ schema: DomainSimulationReceipt['schema'];
102
+ plugin: DomainSimulationReceipt['plugin'];
103
+ pluginVersion: DomainSimulationReceipt['pluginVersion'];
104
+ runId: DomainSimulationReceipt['runId'];
105
+ createdAt: DomainSimulationReceipt['createdAt'];
106
+ modelId: NonNullable<DomainSimulationReceipt['modelId']>;
107
+ solverConfig: {
108
+ solverType: 'spc';
109
+ chartType: ChartType;
110
+ subgroupCount: number;
111
+ totalObservations: number;
112
+ };
113
+ resultSummary: {
114
+ processInControl: boolean;
115
+ outOfControlCount: number;
116
+ capable?: boolean;
117
+ Cpk?: number;
118
+ Ppk?: number;
119
+ };
120
+ cael: {
121
+ version: 'cael.v1';
122
+ event: 'manufacturing_qc.spc';
123
+ solverType: 'manufacturing-qc.spc';
124
+ };
125
+ acceptance: DomainSimulationReceipt['acceptance'];
126
+ payloadHash: DomainSimulationReceipt['payloadHash'];
127
+ hashAlgorithm: DomainSimulationReceipt['hashAlgorithm'];
128
+ }
129
+
130
+ // ─── Control chart constants (ASTM / Montgomery Table VI) ──────────────────────────────────
131
+
132
+ /** d2 unbiasing constants for range → σ̂ */
133
+ const D2: Record<number, number> = {
134
+ 2: 1.128, 3: 1.693, 4: 2.059, 5: 2.326,
135
+ 6: 2.534, 7: 2.704, 8: 2.847, 9: 2.970, 10: 3.078,
136
+ };
137
+
138
+ /** d3 for range chart LCL */
139
+ const D3: Record<number, number> = {
140
+ 2: 0, 3: 0, 4: 0, 5: 0,
141
+ 6: 0, 7: 0.076, 8: 0.136, 9: 0.184, 10: 0.223,
142
+ };
143
+
144
+ /** d4 for range chart UCL */
145
+ const D4: Record<number, number> = {
146
+ 2: 3.267, 3: 2.575, 4: 2.282, 5: 2.115,
147
+ 6: 2.004, 7: 1.924, 8: 1.864, 9: 1.816, 10: 1.777,
148
+ };
149
+
150
+ /** c4 unbiasing constants for s → σ̂ */
151
+ const C4: Record<number, number> = {
152
+ 2: 0.7979, 3: 0.8862, 4: 0.9213, 5: 0.9400,
153
+ 6: 0.9515, 7: 0.9594, 8: 0.9650, 9: 0.9693, 10: 0.9727,
154
+ };
155
+
156
+ /** A2 constants for X̅-R chart (3σ limits via R̅) */
157
+ const A2: Record<number, number> = {
158
+ 2: 1.880, 3: 1.023, 4: 0.729, 5: 0.577,
159
+ 6: 0.483, 7: 0.419, 8: 0.373, 9: 0.337, 10: 0.308,
160
+ };
161
+
162
+ /** A3 constants for X̅-s chart (3σ limits via s̅) */
163
+ const A3: Record<number, number> = {
164
+ 2: 2.659, 3: 1.954, 4: 1.628, 5: 1.427,
165
+ 6: 1.287, 7: 1.182, 8: 1.099, 9: 1.032, 10: 0.975,
166
+ };
167
+
168
+ /** B3 / B4 for s-chart limits */
169
+ const B3: Record<number, number> = {
170
+ 2: 0, 3: 0, 4: 0, 5: 0,
171
+ 6: 0.030, 7: 0.118, 8: 0.185, 9: 0.239, 10: 0.284,
172
+ };
173
+ const B4: Record<number, number> = {
174
+ 2: 3.267, 3: 2.568, 4: 2.266, 5: 2.089,
175
+ 6: 1.970, 7: 1.882, 8: 1.815, 9: 1.761, 10: 1.716,
176
+ };
177
+
178
+ // ─── Statistics helpers ────────────────────────────────────────────────────────────────────
179
+
180
+ function mean(values: number[]): number {
181
+ if (values.length === 0) return 0;
182
+ return values.reduce((s, v) => s + v, 0) / values.length;
183
+ }
184
+
185
+ function sampleStdDev(values: number[]): number {
186
+ if (values.length < 2) return 0;
187
+ const m = mean(values);
188
+ const variance = values.reduce((s, v) => s + (v - m) ** 2, 0) / (values.length - 1);
189
+ return Math.sqrt(variance);
190
+ }
191
+
192
+ function range(values: number[]): number {
193
+ if (values.length === 0) return 0;
194
+ return Math.max(...values) - Math.min(...values);
195
+ }
196
+
197
+ /** Standard normal CDF (Abramowitz & Stegun approximation, error < 7.5e-8) */
198
+ function normalCDF(z: number): number {
199
+ const sign = z < 0 ? -1 : 1;
200
+ const x = Math.abs(z) / Math.SQRT2;
201
+ const t = 1 / (1 + 0.3275911 * x);
202
+ const erf =
203
+ 1 -
204
+ (0.254829592 * t -
205
+ 0.284496736 * t ** 2 +
206
+ 1.421413741 * t ** 3 -
207
+ 1.453152027 * t ** 4 +
208
+ 1.061405429 * t ** 5) *
209
+ Math.exp(-x * x);
210
+ return 0.5 * (1 + sign * erf);
211
+ }
212
+
213
+ function ppm(z: number): number {
214
+ return (1 - normalCDF(z)) * 1_000_000;
215
+ }
216
+
217
+ // ─── Western Electric / Nelson rules ─────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Applies the eight Western Electric rules to a series of standardised
221
+ * (z-score) values. Returns a list of violated rule names per point.
222
+ *
223
+ * Rules:
224
+ * 1: 1 point outside ±3σ
225
+ * 2: 2 of 3 consecutive points outside ±2σ (same side)
226
+ * 3: 4 of 5 consecutive points outside ±1σ (same side)
227
+ * 4: 8 consecutive points on same side of center line
228
+ * 5: 6 consecutive points steadily increasing or decreasing (trend)
229
+ * 6: 15 consecutive points within ±1σ (stratification)
230
+ * 7: 14 consecutive points alternating up/down
231
+ * 8: 8 consecutive points outside ±1σ (mixture)
232
+ */
233
+ function westernElectricViolations(zScores: number[]): string[][] {
234
+ const n = zScores.length;
235
+ const violations: string[][] = Array.from({ length: n }, () => []);
236
+
237
+ for (let i = 0; i < n; i++) {
238
+ const z = zScores[i];
239
+ // Rule 1
240
+ if (Math.abs(z) > 3) violations[i].push('WE1:beyond3sigma');
241
+
242
+ // Rule 2: 2 of 3 on same side beyond 2σ
243
+ if (i >= 2) {
244
+ const window = zScores.slice(i - 2, i + 1);
245
+ if (window.filter((v) => v > 2).length >= 2 || window.filter((v) => v < -2).length >= 2) {
246
+ violations[i].push('WE2:2of3beyond2sigma');
247
+ }
248
+ }
249
+
250
+ // Rule 3: 4 of 5 on same side beyond 1σ
251
+ if (i >= 4) {
252
+ const window = zScores.slice(i - 4, i + 1);
253
+ if (window.filter((v) => v > 1).length >= 4 || window.filter((v) => v < -1).length >= 4) {
254
+ violations[i].push('WE3:4of5beyond1sigma');
255
+ }
256
+ }
257
+
258
+ // Rule 4: 8 consecutive on same side
259
+ if (i >= 7) {
260
+ const window = zScores.slice(i - 7, i + 1);
261
+ if (window.every((v) => v > 0) || window.every((v) => v < 0)) {
262
+ violations[i].push('WE4:8onSameSide');
263
+ }
264
+ }
265
+
266
+ // Rule 5: 6 consecutive monotone
267
+ if (i >= 5) {
268
+ const window = zScores.slice(i - 5, i + 1);
269
+ let inc = true; let dec = true;
270
+ for (let j = 1; j < window.length; j++) {
271
+ if (window[j] <= window[j - 1]) inc = false;
272
+ if (window[j] >= window[j - 1]) dec = false;
273
+ }
274
+ if (inc || dec) violations[i].push('WE5:trend6');
275
+ }
276
+
277
+ // Rule 6: 15 consecutive within 1σ (stratification)
278
+ if (i >= 14) {
279
+ const window = zScores.slice(i - 14, i + 1);
280
+ if (window.every((v) => Math.abs(v) < 1)) violations[i].push('WE6:stratification15');
281
+ }
282
+
283
+ // Rule 7: 14 consecutive alternating
284
+ if (i >= 13) {
285
+ const window = zScores.slice(i - 13, i + 1);
286
+ let alternating = true;
287
+ // Start at j=2: need two consecutive pairs to compare directions.
288
+ // At j=1 there is no prior direction to compare against, so checking
289
+ // there produced a spurious always-true reference (window[0] > window[0]-1)
290
+ // that caused any up-starting alternating sequence to be missed.
291
+ for (let j = 2; j < window.length; j++) {
292
+ const prevDir = window[j - 1] > window[j - 2];
293
+ const currDir = window[j] > window[j - 1];
294
+ if (currDir === prevDir) { alternating = false; break; }
295
+ }
296
+ if (alternating) violations[i].push('WE7:alternating14');
297
+ }
298
+
299
+ // Rule 8: 8 consecutive outside ±1σ (mixture)
300
+ if (i >= 7) {
301
+ const window = zScores.slice(i - 7, i + 1);
302
+ if (window.every((v) => Math.abs(v) > 1)) violations[i].push('WE8:mixture8');
303
+ }
304
+ }
305
+
306
+ return violations;
307
+ }
308
+
309
+ // ─── X̅-R chart ────────────────────────────────────────────────────────────────────────────
310
+
311
+ function buildXbarRChart(subgroups: Subgroup[]): SPCChartResult {
312
+ if (subgroups.length < 2) throw new Error('[spc] X̅-R chart requires ≥ 2 subgroups');
313
+ const n = subgroups[0].values.length;
314
+ if (n < 2 || n > 10) throw new Error('[spc] X̅-R chart subgroup size must be 2–10');
315
+
316
+ const a2 = A2[n] ?? 0.577;
317
+ const d3 = D3[n] ?? 0;
318
+ const d4 = D4[n] ?? 2.115;
319
+
320
+ const means = subgroups.map((sg) => mean(sg.values));
321
+ const ranges = subgroups.map((sg) => range(sg.values));
322
+
323
+ const xbarBar = mean(means);
324
+ const rBar = mean(ranges);
325
+
326
+ const primaryLimits: ControlLimits = {
327
+ centerLine: xbarBar,
328
+ ucl: xbarBar + a2 * rBar,
329
+ lcl: xbarBar - a2 * rBar,
330
+ };
331
+ const secondaryLimits: ControlLimits = {
332
+ centerLine: rBar,
333
+ ucl: d4 * rBar,
334
+ lcl: Math.max(0, d3 * rBar),
335
+ lclFloor: 0,
336
+ };
337
+
338
+ const sigma = rBar / (D2[n] ?? 2.326);
339
+ const primaryZ = means.map((m) => (sigma > 0 ? (m - xbarBar) / sigma : 0));
340
+ const primaryViolations = westernElectricViolations(primaryZ);
341
+
342
+ const subgroupStats: SubgroupStat[] = subgroups.map((sg, i) => {
343
+ const ruleViolations = primaryViolations[i];
344
+ const rangeOOC = ranges[i] > secondaryLimits.ucl || ranges[i] < secondaryLimits.lcl;
345
+ const violations = [...ruleViolations];
346
+ if (rangeOOC) violations.push('R:outOfControl');
347
+ return {
348
+ index: sg.index,
349
+ n,
350
+ mean: means[i],
351
+ range: ranges[i],
352
+ outOfControl: violations.length > 0,
353
+ violatedRules: violations,
354
+ };
355
+ });
356
+
357
+ const oocCount = subgroupStats.filter((s) => s.outOfControl).length;
358
+ return {
359
+ chartType: 'xbar_r',
360
+ subgroupCount: subgroups.length,
361
+ totalObservations: subgroups.length * n,
362
+ primaryChart: primaryLimits,
363
+ secondaryChart: secondaryLimits,
364
+ subgroupStats,
365
+ outOfControlCount: oocCount,
366
+ processInControl: oocCount === 0,
367
+ };
368
+ }
369
+
370
+ // ─── X̅-s chart ────────────────────────────────────────────────────────────────────────────
371
+
372
+ function buildXbarSChart(subgroups: Subgroup[]): SPCChartResult {
373
+ if (subgroups.length < 2) throw new Error('[spc] X̅-s chart requires ≥ 2 subgroups');
374
+ const n = subgroups[0].values.length;
375
+ if (n < 2 || n > 10) throw new Error('[spc] X̅-s chart subgroup size must be 2–10');
376
+
377
+ const a3 = A3[n] ?? 1.427;
378
+ const b3 = B3[n] ?? 0;
379
+ const b4 = B4[n] ?? 2.089;
380
+
381
+ const means = subgroups.map((sg) => mean(sg.values));
382
+ const stds = subgroups.map((sg) => sampleStdDev(sg.values));
383
+
384
+ const xbarBar = mean(means);
385
+ const sBar = mean(stds);
386
+
387
+ const primaryLimits: ControlLimits = {
388
+ centerLine: xbarBar,
389
+ ucl: xbarBar + a3 * sBar,
390
+ lcl: xbarBar - a3 * sBar,
391
+ };
392
+ const secondaryLimits: ControlLimits = {
393
+ centerLine: sBar,
394
+ ucl: b4 * sBar,
395
+ lcl: b3 * sBar,
396
+ lclFloor: 0,
397
+ };
398
+
399
+ const c4val = C4[n] ?? 0.9400;
400
+ const sigma = sBar / c4val;
401
+ const primaryZ = means.map((m) => (sigma > 0 ? (m - xbarBar) / sigma : 0));
402
+ const primaryViolations = westernElectricViolations(primaryZ);
403
+
404
+ const subgroupStats: SubgroupStat[] = subgroups.map((sg, i) => {
405
+ const violations = [...primaryViolations[i]];
406
+ if (stds[i] > secondaryLimits.ucl || stds[i] < secondaryLimits.lcl) violations.push('s:outOfControl');
407
+ return {
408
+ index: sg.index,
409
+ n,
410
+ mean: means[i],
411
+ stdDev: stds[i],
412
+ outOfControl: violations.length > 0,
413
+ violatedRules: violations,
414
+ };
415
+ });
416
+
417
+ const oocCount = subgroupStats.filter((s) => s.outOfControl).length;
418
+ return {
419
+ chartType: 'xbar_s',
420
+ subgroupCount: subgroups.length,
421
+ totalObservations: subgroups.reduce((s, sg) => s + sg.values.length, 0),
422
+ primaryChart: primaryLimits,
423
+ secondaryChart: secondaryLimits,
424
+ subgroupStats,
425
+ outOfControlCount: oocCount,
426
+ processInControl: oocCount === 0,
427
+ };
428
+ }
429
+
430
+ // ─── p-chart (fraction nonconforming) ────────────────────────────────────────────────────────────────
431
+
432
+ function buildPChart(subgroups: Subgroup[]): SPCChartResult {
433
+ if (subgroups.length < 2) throw new Error('[spc] p-chart requires ≥ 2 subgroups');
434
+ for (const sg of subgroups) {
435
+ if (sg.n === undefined || sg.defects === undefined) {
436
+ throw new Error('[spc] p-chart requires .n and .defects on every subgroup');
437
+ }
438
+ }
439
+
440
+ const totInspected = subgroups.reduce((s, sg) => s + (sg.n ?? 0), 0);
441
+ const totDefects = subgroups.reduce((s, sg) => s + (sg.defects ?? 0), 0);
442
+ const pBar = totInspected > 0 ? totDefects / totInspected : 0;
443
+
444
+ const subgroupStats: SubgroupStat[] = subgroups.map((sg) => {
445
+ const n = sg.n ?? 1;
446
+ const d = sg.defects ?? 0;
447
+ const p = n > 0 ? d / n : 0;
448
+ const sigma = Math.sqrt((pBar * (1 - pBar)) / n);
449
+ const ucl = Math.min(1, pBar + 3 * sigma);
450
+ const lcl = Math.max(0, pBar - 3 * sigma);
451
+ const ooc = p > ucl || p < lcl;
452
+ return {
453
+ index: sg.index,
454
+ n,
455
+ proportion: p,
456
+ outOfControl: ooc,
457
+ violatedRules: ooc ? ['p:outOfControl'] : [],
458
+ };
459
+ });
460
+
461
+ // Use average n for overall limits
462
+ const nBar = totInspected / subgroups.length;
463
+ const sigmaBar = Math.sqrt((pBar * (1 - pBar)) / nBar);
464
+ const primaryLimits: ControlLimits = {
465
+ centerLine: pBar,
466
+ ucl: Math.min(1, pBar + 3 * sigmaBar),
467
+ lcl: Math.max(0, pBar - 3 * sigmaBar),
468
+ lclFloor: 0,
469
+ };
470
+
471
+ const oocCount = subgroupStats.filter((s) => s.outOfControl).length;
472
+ return {
473
+ chartType: 'p',
474
+ subgroupCount: subgroups.length,
475
+ totalObservations: totInspected,
476
+ primaryChart: primaryLimits,
477
+ subgroupStats,
478
+ outOfControlCount: oocCount,
479
+ processInControl: oocCount === 0,
480
+ };
481
+ }
482
+
483
+ // ─── c-chart (count of defects, constant n) ──────────────────────────────────────────────────────────
484
+
485
+ function buildCChart(subgroups: Subgroup[]): SPCChartResult {
486
+ if (subgroups.length < 2) throw new Error('[spc] c-chart requires ≥ 2 subgroups');
487
+ for (const sg of subgroups) {
488
+ if (sg.defects === undefined) throw new Error('[spc] c-chart requires .defects on every subgroup');
489
+ }
490
+
491
+ const counts = subgroups.map((sg) => sg.defects ?? 0);
492
+ const cBar = mean(counts);
493
+ const sigma = Math.sqrt(cBar);
494
+
495
+ const primaryLimits: ControlLimits = {
496
+ centerLine: cBar,
497
+ ucl: cBar + 3 * sigma,
498
+ lcl: Math.max(0, cBar - 3 * sigma),
499
+ lclFloor: 0,
500
+ };
501
+
502
+ const zScores = counts.map((c) => (sigma > 0 ? (c - cBar) / sigma : 0));
503
+ const violations = westernElectricViolations(zScores);
504
+
505
+ const subgroupStats: SubgroupStat[] = subgroups.map((sg, i) => ({
506
+ index: sg.index,
507
+ n: sg.n ?? 1,
508
+ rate: counts[i],
509
+ outOfControl: violations[i].length > 0,
510
+ violatedRules: violations[i],
511
+ }));
512
+
513
+ const oocCount = subgroupStats.filter((s) => s.outOfControl).length;
514
+ return {
515
+ chartType: 'c',
516
+ subgroupCount: subgroups.length,
517
+ totalObservations: subgroups.reduce((s, sg) => s + (sg.n ?? 1), 0),
518
+ primaryChart: primaryLimits,
519
+ subgroupStats,
520
+ outOfControlCount: oocCount,
521
+ processInControl: oocCount === 0,
522
+ };
523
+ }
524
+
525
+ // ─── Public API ─────────────────────────────────────────────────────────────────────────────
526
+
527
+ /**
528
+ * Build a Shewhart control chart from subgroup data.
529
+ *
530
+ * Chart type selection:
531
+ * - 'xbar_r' : variables data, subgroup size 2–10 (preferred for n ≤ 7)
532
+ * - 'xbar_s' : variables data, subgroup size 2–10 (preferred for n ≥ 8)
533
+ * - 'p' : fraction nonconforming (variable n), needs .n + .defects
534
+ * - 'np' : count nonconforming (fixed n), needs .n + .defects
535
+ * - 'c' : defect count per unit (fixed opportunity), needs .defects
536
+ * - 'u' : defect rate (variable opportunity), needs .n + .defects
537
+ */
538
+ export function buildSPCChart(type: ChartType, subgroups: Subgroup[]): SPCChartResult {
539
+ switch (type) {
540
+ case 'xbar_r': return buildXbarRChart(subgroups);
541
+ case 'xbar_s': return buildXbarSChart(subgroups);
542
+ case 'p': return buildPChart(subgroups);
543
+ case 'np': {
544
+ // np-chart: same as p-chart but plot count instead of proportion
545
+ const result = buildPChart(subgroups);
546
+ const n = subgroups[0].n ?? 1;
547
+ return {
548
+ ...result,
549
+ chartType: 'np',
550
+ primaryChart: {
551
+ centerLine: result.primaryChart.centerLine * n,
552
+ ucl: result.primaryChart.ucl * n,
553
+ lcl: result.primaryChart.lcl * n,
554
+ lclFloor: 0,
555
+ },
556
+ };
557
+ }
558
+ case 'c': return buildCChart(subgroups);
559
+ case 'u': {
560
+ // u-chart: defects per unit (c-chart normalised by n)
561
+ for (const sg of subgroups) {
562
+ if (sg.n === undefined || sg.defects === undefined) {
563
+ throw new Error('[spc] u-chart requires .n and .defects on every subgroup');
564
+ }
565
+ }
566
+ const rates = subgroups.map((sg) => (sg.defects ?? 0) / (sg.n ?? 1));
567
+ const uBar = mean(rates);
568
+ const nBar = mean(subgroups.map((sg) => sg.n ?? 1));
569
+ const sigma = Math.sqrt(uBar / nBar);
570
+ const primaryLimits: ControlLimits = {
571
+ centerLine: uBar,
572
+ ucl: uBar + 3 * sigma,
573
+ lcl: Math.max(0, uBar - 3 * sigma),
574
+ lclFloor: 0,
575
+ };
576
+ const zScores = rates.map((u) => (sigma > 0 ? (u - uBar) / sigma : 0));
577
+ const violations = westernElectricViolations(zScores);
578
+ const subgroupStats: SubgroupStat[] = subgroups.map((sg, i) => ({
579
+ index: sg.index,
580
+ n: sg.n ?? 1,
581
+ rate: rates[i],
582
+ outOfControl: violations[i].length > 0,
583
+ violatedRules: violations[i],
584
+ }));
585
+ const oocCount = subgroupStats.filter((s) => s.outOfControl).length;
586
+ return {
587
+ chartType: 'u',
588
+ subgroupCount: subgroups.length,
589
+ totalObservations: subgroups.reduce((s, sg) => s + (sg.n ?? 1), 0),
590
+ primaryChart: primaryLimits,
591
+ subgroupStats,
592
+ outOfControlCount: oocCount,
593
+ processInControl: oocCount === 0,
594
+ };
595
+ }
596
+ default:
597
+ throw new Error(`[spc] unknown chart type: ${String(type)}`);
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Compute process capability indices from variables data.
603
+ *
604
+ * @param allValues All individual measurements (flattened across subgroups)
605
+ * @param subgroups Original subgroups (used for within-subgroup σ̂ via R̅/d₂)
606
+ * @param lsl Lower specification limit
607
+ * @param usl Upper specification limit
608
+ * @param target Nominal target (optional; used for Cpm)
609
+ */
610
+ export function computeCapability(
611
+ allValues: number[],
612
+ subgroups: Subgroup[],
613
+ lsl: number,
614
+ usl: number,
615
+ target?: number,
616
+ ): ProcessCapability {
617
+ if (usl <= lsl) throw new Error('[spc] usl must be > lsl');
618
+ if (allValues.length < 2) throw new Error('[spc] need ≥ 2 values for capability');
619
+
620
+ const processMean = mean(allValues);
621
+ // Overall (long-term) σ
622
+ const overallSigma = sampleStdDev(allValues);
623
+
624
+ // Within-subgroup (short-term) σ̂ via R̅/d₂ if all subgroups same size
625
+ let withinSigma = overallSigma;
626
+ const n = subgroups[0]?.values.length ?? 0;
627
+ const sameSize = subgroups.every((sg) => sg.values.length === n);
628
+ if (sameSize && n >= 2 && n <= 10) {
629
+ const d2val = D2[n] ?? 2.326;
630
+ const rBar = mean(subgroups.map((sg) => range(sg.values)));
631
+ withinSigma = rBar / d2val;
632
+ if (withinSigma === 0) withinSigma = overallSigma;
633
+ }
634
+
635
+ const specWidth = usl - lsl;
636
+ const Cp = withinSigma > 0 ? specWidth / (6 * withinSigma) : Infinity;
637
+ const Pp = overallSigma > 0 ? specWidth / (6 * overallSigma) : Infinity;
638
+
639
+ const CpkUpper = withinSigma > 0 ? (usl - processMean) / (3 * withinSigma) : Infinity;
640
+ const CpkLower = withinSigma > 0 ? (processMean - lsl) / (3 * withinSigma) : Infinity;
641
+ const Cpk = Math.min(CpkUpper, CpkLower);
642
+
643
+ const PpkUpper = overallSigma > 0 ? (usl - processMean) / (3 * overallSigma) : Infinity;
644
+ const PpkLower = overallSigma > 0 ? (processMean - lsl) / (3 * overallSigma) : Infinity;
645
+ const Ppk = Math.min(PpkUpper, PpkLower);
646
+
647
+ let Cpm: number | undefined;
648
+ if (target !== undefined && withinSigma > 0) {
649
+ const tauSq = withinSigma ** 2 + (processMean - target) ** 2;
650
+ Cpm = specWidth / (6 * Math.sqrt(tauSq));
651
+ }
652
+
653
+ const zAboveUSL = overallSigma > 0 ? (usl - processMean) / overallSigma : Infinity;
654
+ const zBelowLSL = overallSigma > 0 ? (processMean - lsl) / overallSigma : Infinity;
655
+ const ppmAboveUSL = ppm(zAboveUSL);
656
+ const ppmBelowLSL = ppm(zBelowLSL);
657
+
658
+ return {
659
+ lsl, usl, target,
660
+ processMean,
661
+ processStdDev: overallSigma,
662
+ Cp, Cpk, CpkLower, CpkUpper,
663
+ Pp, Ppk,
664
+ Cpm,
665
+ ppmAboveUSL,
666
+ ppmBelowLSL,
667
+ ppmTotal: ppmAboveUSL + ppmBelowLSL,
668
+ capable: Cpk >= 1.33,
669
+ };
670
+ }
671
+
672
+ /**
673
+ * Build a CAEL-ready SPC receipt.
674
+ */
675
+ export function buildSPCReceipt(
676
+ modelId: string,
677
+ chartResult: SPCChartResult,
678
+ capability?: ProcessCapability,
679
+ options: SPCReceiptOptions = {},
680
+ ): SPCReceipt {
681
+ const violations: Array<{ criterion: string; message: string }> = [];
682
+
683
+ if (!chartResult.processInControl) {
684
+ violations.push({
685
+ criterion: 'process_control',
686
+ message: `${chartResult.outOfControlCount} out-of-control subgroup(s) detected`,
687
+ });
688
+ }
689
+ if (capability && !capability.capable) {
690
+ violations.push({
691
+ criterion: 'capability',
692
+ message: `Cpk=${capability.Cpk.toFixed(3)} < 1.33 (process not capable)`,
693
+ });
694
+ }
695
+
696
+ const receipt = buildDomainSimulationReceipt({
697
+ plugin: 'manufacturing-qc' as const,
698
+ pluginVersion: '1.0.0',
699
+ runId: options.runId ?? `spc-${Date.now().toString(36)}`,
700
+ createdAt: options.createdAt,
701
+ modelId,
702
+ solverConfig: {
703
+ solverType: 'spc',
704
+ scale: 'production',
705
+ chartType: chartResult.chartType,
706
+ subgroupCount: chartResult.subgroupCount,
707
+ totalObservations: chartResult.totalObservations,
708
+ },
709
+ resultSummary: {
710
+ processInControl: chartResult.processInControl,
711
+ outOfControlCount: chartResult.outOfControlCount,
712
+ capable: capability?.capable ?? null,
713
+ Cpk: capability?.Cpk ?? null,
714
+ Ppk: capability?.Ppk ?? null,
715
+ },
716
+ cael: {
717
+ version: 'cael.v1',
718
+ event: 'manufacturing_qc.spc',
719
+ solverType: 'manufacturing-qc.spc',
720
+ },
721
+ acceptance: { accepted: violations.length === 0, violations },
722
+ });
723
+
724
+ return receipt as unknown as SPCReceipt;
725
+ }
726
+
727
+ export const SPC_PLUGIN_VERSION = '1.0.0';
728
+ export const DOMAIN_SIMULATION_RECEIPT_SCHEMA_REF = DOMAIN_SIMULATION_RECEIPT_SCHEMA;