formlab-mcp 0.6.45 → 0.6.47
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/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +55 -10
- package/tools/designspace.js +12 -8
- package/tools/library.js +60 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.47",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
5
|
"description": "Read-only Model Context Protocol server for FormLab \u2014 lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.juliu1980/formlab-mcp",
|
|
4
4
|
"description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
|
|
5
|
-
"version": "0.6.
|
|
5
|
+
"version": "0.6.47",
|
|
6
6
|
"websiteUrl": "https://formvix.com/mcp",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/juliu1980/FormLab",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "formlab-mcp",
|
|
16
|
-
"version": "0.6.
|
|
16
|
+
"version": "0.6.47",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -359,13 +359,23 @@ const get_doe_matrix = {
|
|
|
359
359
|
|
|
360
360
|
// 4) Aggregate measurements per column per row (mean of numeric scalar
|
|
361
361
|
// values), reading only the column's own run condition when split.
|
|
362
|
+
const testOfM = new Map();
|
|
363
|
+
(db.testResults || []).forEach(t => (t.measurements || []).forEach(x => testOfM.set(x, t)));
|
|
362
364
|
const cellParamValue = (r, col) => {
|
|
363
365
|
const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
|
|
364
366
|
if (!ms.length) return null;
|
|
365
367
|
// The app's DOE Matrix numbers (12v): a curve's final value, a
|
|
366
|
-
// distribution's D50,
|
|
368
|
+
// distribution's D50, a grade's place on its scale. 13r: a ranked scale
|
|
369
|
+
// is its LOWER-median grade (never an averaged rank); text its most
|
|
370
|
+
// frequent value, a tie to a failing one (it took the first reading); a
|
|
371
|
+
// distribution with no D50 its bins (it read "8 bins").
|
|
367
372
|
const nums = ms.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
|
|
368
|
-
if (!nums.length)
|
|
373
|
+
if (!nums.length) {
|
|
374
|
+
const rows = ms.map(m => { const t = testOfM.get(m); return { m, type: _mcpMeasType(m), value: m.value, numeric: null, rank: null, pf: _evalPassFail(m, t && t.templateId) }; });
|
|
375
|
+
if (rows.every(x => x.type === 'categorical')) return _mcpGradedPick(rows).value ?? null;
|
|
376
|
+
return _mcpCellValue(rows[0]);
|
|
377
|
+
}
|
|
378
|
+
if (ms.every(m => _mcpMeasType(m) === 'ordinal')) { const so = nums.slice().sort((a, b) => a - b); return so[Math.floor((so.length - 1) / 2)]; }
|
|
369
379
|
const mean = nums.reduce((s, v) => s + v, 0) / nums.length;
|
|
370
380
|
return +mean.toFixed(4);
|
|
371
381
|
};
|
|
@@ -462,7 +472,7 @@ function _mcpRepNumber(m, opts) {
|
|
|
462
472
|
// app's Stability view draws it (FormLab 12w): one point per test date × run
|
|
463
473
|
// condition — an ordinal date is the MEDIAN rank of its readings (rank =
|
|
464
474
|
// 1-based position on the reading's own scale), a text date its value (or
|
|
465
|
-
//
|
|
475
|
+
// the most frequent value, a tie to a failing one) — failing when any reading fails; one series per condition.
|
|
466
476
|
function _mcpGradedStability(entity, entityType, args, graded) {
|
|
467
477
|
const nOrd = graded.filter(r => r.type === 'ordinal').length;
|
|
468
478
|
const kind = nOrd >= graded.length - nOrd ? 'ordinal' : 'categorical';
|
|
@@ -478,8 +488,11 @@ function _mcpGradedStability(entity, entityType, args, graded) {
|
|
|
478
488
|
rank = rs.length ? rs[Math.floor((rs.length - 1) / 2)] : null;
|
|
479
489
|
label = rank != null && scale ? scale[rank - 1] : g[0].label;
|
|
480
490
|
} else {
|
|
481
|
-
|
|
482
|
-
|
|
491
|
+
// The most frequent value, a tie to a failing one (13r) — "mixed: a / b" said
|
|
492
|
+
// nothing about which reading counts. The chip's tooltip lists them all.
|
|
493
|
+
const k = r => String(r.label).toLowerCase(); const cnt = new Map(); g.forEach(r => cnt.set(k(r), (cnt.get(k(r)) || 0) + 1));
|
|
494
|
+
const top = Math.max(...cnt.values()); const tied = g.filter(r => cnt.get(k(r)) === top);
|
|
495
|
+
label = (tied.find(r => r.pf === 'fail') || tied[0]).label;
|
|
483
496
|
}
|
|
484
497
|
return { date: g[0].day, condition: split ? (g[0].cond || null) : null, label, ...(kind === 'ordinal' ? { rank } : {}),
|
|
485
498
|
readings: g.length, values: g.map(r => r.label), reports: [...new Set(g.map(r => r.report))],
|
|
@@ -504,7 +517,7 @@ function _mcpGradedStability(entity, entityType, args, graded) {
|
|
|
504
517
|
parameter: args.parameter, kind, ...(scale ? { scale } : {}), spec, byCondition: split, pointCount: pts.length, series,
|
|
505
518
|
note: kind === 'ordinal'
|
|
506
519
|
? 'A ranked scale (ordinal) has no number, so there is no regression, control chart or shelf-life projection: each point is one test date per condition, its value the MEDIAN rank of that date\'s readings (rank = position on `scale`, 1-based; higher = later on the scale). pf "fail" when any reading that date failed. scaleShift.steps is the rank change first → latest (negative = moved down the scale). Narrate firstFail / latest; do not average ranks into a number.'
|
|
507
|
-
: 'A text (categorical) result has no number: each point is one test date per condition with its value (
|
|
520
|
+
: 'A text (categorical) result has no number: each point is one test date per condition with its value (several readings that differ → the most frequent, a tie to a failing one; `values` lists them) and pass/fail against `spec.required`; pf "fail" when any reading failed. Narrate firstFail / latest.',
|
|
508
521
|
};
|
|
509
522
|
}
|
|
510
523
|
function _mcpCondSig(m) {
|
|
@@ -812,7 +825,8 @@ function _evalPassFail(m, templateId, db) {
|
|
|
812
825
|
if (type === 'ordinal') {
|
|
813
826
|
if (!raw) return 'untested';
|
|
814
827
|
const scale = Array.isArray(m.scale) ? m.scale : [];
|
|
815
|
-
|
|
828
|
+
// Case-insensitive, as flMeasNum places a grade (13r): "hb" was judged untested but plotted at HB.
|
|
829
|
+
const idx = scale.map(g => String(g).trim().toUpperCase()).indexOf(raw.toUpperCase());
|
|
816
830
|
if (idx < 0) return 'untested';
|
|
817
831
|
const minRank = _ordinalMinRank(crit, scale);
|
|
818
832
|
if (minRank == null) return 'no-criteria';
|
|
@@ -1437,7 +1451,7 @@ const get_stability = {
|
|
|
1437
1451
|
},
|
|
1438
1452
|
};
|
|
1439
1453
|
|
|
1440
|
-
export { _mcpRepNumber, _mcpMeasType, _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
|
|
1454
|
+
export { _mcpRepNumber, _mcpMeasType, _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore, _mcpCondSig, _mcpCondLabel, _mcpParamGroupsOf };
|
|
1441
1455
|
// ============================================================
|
|
1442
1456
|
// compare_test_results — the app's Compare Results table (js/views/testing-
|
|
1443
1457
|
// compare.js), 13g. Items are formulas, batches, samples or test reports; each
|
|
@@ -1511,8 +1525,39 @@ function _mcpCompareCell(item, parameter, agg, db) {
|
|
|
1511
1525
|
if (!arms.has(sig)) arms.set(sig, { sig, m, t, rows: [] });
|
|
1512
1526
|
arms.get(sig).rows.push({ m, t, date: _tcDate(t), value: v, pf: _evalPassFail(m, t.templateId, db) });
|
|
1513
1527
|
});
|
|
1514
|
-
//
|
|
1515
|
-
// the latest
|
|
1528
|
+
// A ranked scale or a text result (13r, as the app's Compare): per run
|
|
1529
|
+
// condition, no condition first, the latest test date — several readings on
|
|
1530
|
+
// it → the lower-median grade / the most frequent text (a tie to a failing
|
|
1531
|
+
// one), failing if any reading fails; two or more conditions → arms.
|
|
1532
|
+
if (!arms.size && (type === 'ordinal' || type === 'categorical')) {
|
|
1533
|
+
const g = new Map();
|
|
1534
|
+
hits.forEach(({ m, t }) => {
|
|
1535
|
+
const label = String(m.value == null ? '' : m.value).trim(); if (!label) return;
|
|
1536
|
+
const sig = _mcpCondSig(m), day = String(t.testDate || '').slice(0, 10);
|
|
1537
|
+
if (!g.has(sig)) g.set(sig, { sig, m, days: new Map() });
|
|
1538
|
+
const a = g.get(sig); if (!a.days.has(day)) a.days.set(day, []);
|
|
1539
|
+
a.days.get(day).push({ type: _mcpMeasType(m), value: label, rank: _mcpRepNumber(m, { ordinal: 'rank' }), pf: _evalPassFail(m, t.templateId, db), m });
|
|
1540
|
+
});
|
|
1541
|
+
const gl = [...g.values()].sort((x, y) => ((x.sig ? 1 : 0) - (y.sig ? 1 : 0)) || (y.days.size - x.days.size) || x.sig.localeCompare(y.sig));
|
|
1542
|
+
if (gl.length) {
|
|
1543
|
+
const cellOf = (a) => {
|
|
1544
|
+
const day = [...a.days.keys()].sort().pop(); const rs = a.days.get(day); const p = _mcpGradedPick(rs);
|
|
1545
|
+
const pf = rs.some(r => r.pf === 'fail') ? 'fail' : rs.some(r => r.pf === 'pass') ? 'pass' : rs[0].pf;
|
|
1546
|
+
const c = { type, value: p.value, pf, date: day, n: a.days.size, readings: [...a.days.values()].reduce((x, r) => x + r.length, 0),
|
|
1547
|
+
...(rs.length > 1 ? { readingsOnDate: rs.map(r => r.value), basis: type === 'ordinal' ? 'middle grade of the date\'s readings' : 'most frequent of the date\'s readings' } : {}) };
|
|
1548
|
+
if (type === 'ordinal' && p.rank != null && Array.isArray(p.m.scale)) { c.rank = p.rank; c.scaleLength = p.m.scale.length; }
|
|
1549
|
+
return c;
|
|
1550
|
+
};
|
|
1551
|
+
const cells = gl.map(cellOf);
|
|
1552
|
+
if (agg !== 'latest' && hits.length > 1) cells.forEach(c => { c.note = 'not a number — the latest test date is shown'; });
|
|
1553
|
+
if (gl.length === 1) return { ...cells[0], ...(gl[0].sig ? { condition: _mcpCondLabel(gl[0].m) } : {}) };
|
|
1554
|
+
const tags = _mcpCondTags(gl.map(a => a.sig ? _mcpCondLabel(a.m) : ''));
|
|
1555
|
+
return { ...cells[0], splitByCondition: true,
|
|
1556
|
+
arms: gl.map((a, i) => ({ condition: a.sig ? _mcpCondLabel(a.m) : null, tag: tags[i], ...cells[i] })),
|
|
1557
|
+
note: 'measured under more than one run condition: one arm per condition, never mixed; the top-level value is the first arm (no run condition when there is one)' };
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
// Nothing numeric (a distribution with no D50): the latest reading.
|
|
1516
1561
|
if (!arms.size) {
|
|
1517
1562
|
const { m, t } = hits[hits.length - 1];
|
|
1518
1563
|
const out = { type, value: m.value ?? null, unit: m.unit || '', pf: _evalPassFail(m, t.templateId, db), date: t.testDate || '', readings: hits.length };
|
package/tools/designspace.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// ============================================================
|
|
28
28
|
|
|
29
29
|
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
30
|
-
import { _mcpRepNumber } from './analytics.js';
|
|
30
|
+
import { _mcpRepNumber, _mcpMeasType } from './analytics.js';
|
|
31
31
|
|
|
32
32
|
// ---- composition helper -----------------------------------------------------
|
|
33
33
|
// Ingredient wt-% map for one formula, mirroring the app's _asmIngredientMap:
|
|
@@ -346,20 +346,24 @@ function _roleCandidates(forms, roleMaps) {
|
|
|
346
346
|
return out.sort((a, b) => b.score - a.score);
|
|
347
347
|
}
|
|
348
348
|
// A formula's value for a measured parameter, as the app's
|
|
349
|
-
// _asmFormulaValueForCol
|
|
350
|
-
//
|
|
351
|
-
//
|
|
349
|
+
// _asmFormulaValueForCol: the plain-name readings — no run condition when
|
|
350
|
+
// there are any (13q; it took the condition with the most readings), else the
|
|
351
|
+
// most readings — each read as the DOE Matrix reads it (a curve's final value,
|
|
352
|
+
// a distribution's D50, a grade's place on its scale), then their mean; a
|
|
353
|
+
// ranked scale's middle grade (lower median), never an averaged rank.
|
|
352
354
|
function _paramValue(f, param, db) {
|
|
353
355
|
const sids = new Set((db.samples || []).filter(s => s.formulationId === f.id).map(s => s.id));
|
|
354
356
|
const ms = [];
|
|
355
357
|
(db.testResults || []).forEach(t => { if (!sids.has(t.sampleId)) return; (t.measurements || []).forEach(m => { if (m.parameter === param) ms.push(m); }); });
|
|
356
358
|
const sig = (m) => { const c = m && m.conditions; return c && typeof c === 'object' ? Object.keys(c).filter(k => c[k] != null && c[k] !== '').sort().map(k => k + '=' + c[k]).join('; ') : ''; };
|
|
357
359
|
const byCond = new Map();
|
|
358
|
-
ms.forEach(m => { if (_mcpRepNumber(m) == null) return; const k = sig(m); byCond.set(k, (byCond.get(k) || 0) + 1); });
|
|
359
|
-
const primary = [...byCond.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
360
|
+
ms.forEach(m => { if (_mcpRepNumber(m, { ordinal: 'rank' }) == null) return; const k = sig(m); byCond.set(k, (byCond.get(k) || 0) + 1); });
|
|
361
|
+
const primary = [...byCond.entries()].sort((a, b) => ((a[0] ? 1 : 0) - (b[0] ? 1 : 0)) || (b[1] - a[1]) || a[0].localeCompare(b[0]))[0];
|
|
360
362
|
const pool = primary ? ms.filter(m => sig(m) === primary[0]) : ms;
|
|
361
363
|
const vals = pool.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
|
|
362
|
-
|
|
364
|
+
if (!vals.length) return null;
|
|
365
|
+
if (pool.some(m => _mcpMeasType(m) === 'ordinal')) { const so = vals.slice().sort((a, b) => a - b); return so[Math.floor((so.length - 1) / 2)]; }
|
|
366
|
+
return vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
363
367
|
}
|
|
364
368
|
function _spearman(xs, ys) {
|
|
365
369
|
const rank = (a) => { const idx = a.map((v, i) => [v, i]).sort((p, q) => p[0] - q[0]); const r = new Array(a.length); let i = 0; while (i < idx.length) { let j = i; while (j + 1 < idx.length && idx[j + 1][0] === idx[i][0]) j++; const avg = (i + j) / 2 + 1; for (let k = i; k <= j; k++) r[idx[k][1]] = avg; i = j + 1; } return r; };
|
|
@@ -503,7 +507,7 @@ const get_design_space = {
|
|
|
503
507
|
if (vals.length < 2) out.notes.push(`"${args.property}" is measured on ${vals.length} of the formulas on the map — need at least 2 for insights (check the exact parameter name).`);
|
|
504
508
|
else {
|
|
505
509
|
const hi = vals.reduce((a, b) => b.v > a.v ? b : a), lo = vals.reduce((a, b) => b.v < a.v ? b : a);
|
|
506
|
-
const pi = { property: args.property, basis: 'mean
|
|
510
|
+
const pi = { property: args.property, basis: 'per formula: the readings with no run condition (else the condition tested most) — the mean, a curve at its final point, a distribution at its D50; a ranked scale as its middle grade\'s place on the scale', formulasMeasured: vals.length,
|
|
507
511
|
highest: { formula: hi.f.name, uid: hi.f.uid || null, value: +hi.v.toFixed(4) },
|
|
508
512
|
lowest: { formula: lo.f.name, uid: lo.f.uid || null, value: +lo.v.toFixed(4) } };
|
|
509
513
|
if (vals.length >= 4) {
|
package/tools/library.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// ============================================================
|
|
13
13
|
|
|
14
14
|
import { getStore, resolveById } from '../data.js';
|
|
15
|
-
import { _evalPassFail } from './analytics.js';
|
|
15
|
+
import { _evalPassFail, _mcpRepNumber, _mcpMeasType, _resolveSpec, _mcpCondSig, _mcpCondLabel, _mcpCondTags, _mcpParamGroupsOf } from './analytics.js';
|
|
16
16
|
|
|
17
17
|
// ── Usage roll-ups (mirror the Usage tabs on the Panel / Method / Equipment
|
|
18
18
|
// detail pages) ────────────────────────────────────────────────────────
|
|
@@ -26,7 +26,23 @@ function _resolveInstrument(m, t, db) {
|
|
|
26
26
|
if (t && (t.equipmentId || t.instrument)) return { equipmentId: t.equipmentId || '', name: t.instrument || '', source: 'run' };
|
|
27
27
|
return { equipmentId: '', name: '', source: '' };
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
// A reading's number, the app's rule (13q): a curve's final point, a
|
|
30
|
+
// distribution at the statistic its spec names (D90) or D50, nothing for a
|
|
31
|
+
// grade or text. It was parseFloat(m.value): a curve's point count, "6B" → 6.
|
|
32
|
+
function _num(m, t) {
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const sp = _mcpMeasType(m) === 'distribution' ? (_resolveSpec(m, t && t.templateId).spec || null) : null;
|
|
35
|
+
const v = sp && sp.stat ? _mcpRepNumber(m, { dist: String(sp.stat) }) : _mcpRepNumber(m);
|
|
36
|
+
return v != null && Number.isFinite(v) ? v : null;
|
|
37
|
+
}
|
|
38
|
+
// Condition tags for one parameter's arms ('' first): { key → "40 °C" }, only
|
|
39
|
+
// when there are two or more.
|
|
40
|
+
function _condTagMap(keys) {
|
|
41
|
+
const ks = [...new Set(keys)].sort((x, y) => ((x ? 1 : 0) - (y ? 1 : 0)) || x.localeCompare(y));
|
|
42
|
+
if (ks.length < 2) return new Map();
|
|
43
|
+
const tags = _mcpCondTags(ks.map(k => k ? k.split('|').map(p => p.replace('=', ' ')).join(' · ') : ''));
|
|
44
|
+
return new Map(ks.map((k, i) => [k, tags[i]]));
|
|
45
|
+
}
|
|
30
46
|
function _mean(a) { return a.length ? a.reduce((x, y) => x + y, 0) / a.length : null; }
|
|
31
47
|
function _sd(a) { if (a.length < 2) return null; const mu = _mean(a); return Math.sqrt(a.reduce((acc, x) => acc + (x - mu) ** 2, 0) / (a.length - 1)); }
|
|
32
48
|
function _r(v, d = 3) { return v == null ? null : +v.toFixed(d); }
|
|
@@ -163,18 +179,32 @@ const get_equipment = {
|
|
|
163
179
|
const nm = _norm(e.name);
|
|
164
180
|
const mine = (m, t) => { const r = _resolveInstrument(m, t, db); return r.equipmentId ? r.equipmentId === e.id : (!!nm && _norm(r.name) === nm); };
|
|
165
181
|
const tests = (db.testResults || []).filter(t => (t.measurements || []).some(m => mine(m, t)));
|
|
166
|
-
|
|
182
|
+
// Per parameter × RUN CONDITION (13q, as the app): a unit that ran the
|
|
183
|
+
// 40 °C pulls was compared with everyone's ambient readings.
|
|
184
|
+
const here = new Map(), fleet = new Map(), pairs = [];
|
|
167
185
|
(db.testResults || []).forEach(t => (t.measurements || []).forEach(m => {
|
|
168
186
|
const pn = (m.parameter || '').trim(); if (!pn) return;
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
187
|
+
const cond = _mcpCondSig(m);
|
|
188
|
+
const v = _num(m, t); const isMine = mine(m, t); const bucket = isMine ? here : fleet;
|
|
189
|
+
if (isMine) pairs.push({ m, t });
|
|
190
|
+
const k = pn + '\u0000' + cond;
|
|
191
|
+
if (!bucket.has(k)) bucket.set(k, { k, parameter: pn, cond, m0: m, readings: 0, vals: [], fails: 0 });
|
|
192
|
+
const g = bucket.get(k); g.readings++; if (v != null) g.vals.push(v); if (isMine && _evalPassFail(m, t.templateId) === 'fail') g.fails++;
|
|
172
193
|
}));
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
194
|
+
// The app's order: type groups, the test panel's order; no condition first.
|
|
195
|
+
const order = _mcpParamGroupsOf(pairs, 'method', db).flatMap(g => g.parameters);
|
|
196
|
+
const oi = (pn) => { const i = order.indexOf(pn); return i < 0 ? 1e6 : i; };
|
|
197
|
+
const tagsBy = new Map();
|
|
198
|
+
here.forEach(g => { if (!tagsBy.has(g.parameter)) tagsBy.set(g.parameter, []); tagsBy.get(g.parameter).push(g.cond); });
|
|
199
|
+
const measured = [...here.values()]
|
|
200
|
+
.sort((a, b) => (oi(a.parameter) - oi(b.parameter)) || ((a.cond ? 1 : 0) - (b.cond ? 1 : 0)) || a.cond.localeCompare(b.cond))
|
|
201
|
+
.map(g => {
|
|
202
|
+
const f = fleet.get(g.k); const mh = _mean(g.vals), me = f ? _mean(f.vals) : null;
|
|
203
|
+
const tag = _condTagMap(tagsBy.get(g.parameter)).get(g.cond);
|
|
204
|
+
return { parameter: g.parameter, ...(g.cond ? { condition: _mcpCondLabel(g.m0), ...(tag ? { column: `${g.parameter} · ${tag}` } : {}) } : {}),
|
|
205
|
+
readings: g.readings, mean: _r(mh), fails: g.fails, readingsElsewhere: f ? f.readings : 0, meanElsewhere: _r(me),
|
|
206
|
+
biasPct: (mh != null && me != null && me !== 0) ? _r((mh - me) / Math.abs(me) * 100, 1) : null };
|
|
207
|
+
});
|
|
178
208
|
const usedIn = _equipUsage(e, db);
|
|
179
209
|
usedIn.testReports = tests.length;
|
|
180
210
|
return { ...(_trimEquipment(e)), usedIn, usage: _usageRollup(db, tests, mine), measured };
|
|
@@ -278,15 +308,28 @@ const get_test_method = {
|
|
|
278
308
|
// Usage = every measurement linked to this method (by id, else by exact name).
|
|
279
309
|
const match = (m) => (m.parameterId && m.parameterId === p.id) || (!m.parameterId && _norm(m.parameter) === nm);
|
|
280
310
|
const tests = (db.testResults || []).filter(r => (r.measurements || []).some(match));
|
|
281
|
-
|
|
311
|
+
// Values by RUN CONDITION (13q, as the app): the summary is the plain-name
|
|
312
|
+
// condition (no condition when there is any, else the most readings), with
|
|
313
|
+
// byCondition when there are two or more; byInstrument within a condition.
|
|
314
|
+
const byCond = new Map(), byInst = new Map(), condM = new Map();
|
|
282
315
|
tests.forEach(r => (r.measurements || []).filter(match).forEach(m => {
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
if (!
|
|
316
|
+
const cond = _mcpCondSig(m); if (!condM.has(cond)) condM.set(cond, m);
|
|
317
|
+
const v = _num(m, r);
|
|
318
|
+
if (!byCond.has(cond)) byCond.set(cond, []);
|
|
319
|
+
if (v != null) byCond.get(cond).push(v);
|
|
320
|
+
const inst = _resolveInstrument(m, r, db); const ik = inst.name || inst.equipmentId || '(unresolved)';
|
|
321
|
+
const key = ik + '\u0000' + cond;
|
|
322
|
+
if (!byInst.has(key)) byInst.set(key, { instrument: ik, cond, readings: 0, vals: [], fails: 0 });
|
|
286
323
|
const g = byInst.get(key); g.readings++; if (v != null) g.vals.push(v); if (_evalPassFail(m, r.templateId) === 'fail') g.fails++;
|
|
287
324
|
}));
|
|
288
|
-
const
|
|
289
|
-
const
|
|
325
|
+
const conds = [...byCond.keys()].sort((x, y) => ((x ? 1 : 0) - (y ? 1 : 0)) || (byCond.get(y).length - byCond.get(x).length) || x.localeCompare(y));
|
|
326
|
+
const primary = conds.length ? conds[0] : '';
|
|
327
|
+
const sum = (vals) => ({ readings: vals.length, mean: _r(_mean(vals)), sd: _r(_sd(vals)), min: vals.length ? Math.min(...vals) : null, max: vals.length ? Math.max(...vals) : null });
|
|
328
|
+
const condName = (c) => c ? _mcpCondLabel(condM.get(c)) : null;
|
|
329
|
+
const byInstrument = [...byInst.values()].map(g => ({ instrument: g.instrument, ...(conds.length > 1 ? { condition: condName(g.cond) } : {}), readings: g.readings, mean: _r(_mean(g.vals)), fails: g.fails })).sort((a, b) => b.readings - a.readings);
|
|
330
|
+
const valueSummary = { ...sum(byCond.get(primary) || []), unit: p.defaultUnit || '',
|
|
331
|
+
...(conds.length > 1 ? { condition: condName(primary), note: 'measured under more than one run condition: this summary is the readings with no condition (else the condition with the most readings); byCondition has each, never averaged together',
|
|
332
|
+
byCondition: conds.map(c => ({ condition: condName(c), ...sum(byCond.get(c)) })) } : {}) };
|
|
290
333
|
return { ...(_trimMethod(p, { full: true })), analyteSiblings: siblings, usingPanels, usage: _usageRollup(db, tests, match), valueSummary, byInstrument };
|
|
291
334
|
},
|
|
292
335
|
};
|