formlab-mcp 0.6.39 → 0.6.41
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/README.md +2 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +224 -12
- package/tools/designspace.js +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
16
16
|
|
|
17
17
|
- _"Which of my formulations use silicone fluid at 5% or more?"_
|
|
18
18
|
- _"Compare the composition of Formula FORM-001 and FORM-004 side by side."_
|
|
19
|
+
- _"Compare the test results of Coating A and Coating B — which parameters differ, and does either fail?"_
|
|
19
20
|
- _"What parameters fail most often in Q2 testing?"_
|
|
20
21
|
- _"Show me the DOE matrix at sample grain for all 'Anti-aging serum' family formulas."_
|
|
21
22
|
- _"What's untested? Which of my approved formulas have no measurements yet?"_
|
|
@@ -48,6 +49,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
48
49
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters. A parameter measured under two or more **run conditions** (storage 25 °C vs 40 °C) is one column per condition (`pH`, `pH · 40 °C`; the bare name is the base arm), never averaged together — same columns as the app's DOE Matrix |
|
|
49
50
|
| `find_failures` | Parameters ranked by failure **rate** (failing ÷ all readings; replicates in one report count once), `lowN` flag under 5 readings, plus formulations ranked by share of failing reports — same numbers as the app's Test Analytics → Failures |
|
|
50
51
|
| `get_coverage_matrix` | TEST coverage by formulation, batch or sample (`grain`) × parameter. Each cell is the **latest** reading by test date (same-day reports averaged, any fail → fail) with its date and how many reports measured it; one column per **run condition** when a parameter was measured under two or more — same rollup as the app's Test Analytics → Coverage |
|
|
52
|
+
| `compare_test_results` | **Test results** side by side across 2+ formulas, batches, samples or reports — the app's Compare Results table. Parameters grouped by measurement type (Numbers, Curves, Distributions, Ranked scales, Text results) and, within a group, in the test panel's order (`order: method`, default) or `az`. One cell per item × parameter: `aggregation` `latest` (default; one value per test date, replicates averaged ± sd), `mean` (± sd between dates), `median` (min–max) or `trend` (change from the first date). Curves compare at their final point, distributions at D50; ranked scales and text show the latest reading (with its rank); a parameter measured under 2+ run conditions is split into `arms`, never averaged. `differs` flags a >5% spread across items, as the app highlights |
|
|
51
53
|
| `compare_batches` | Reproducibility of 2+ runs (ideally of one formula): each run's **yield %**, actual produced mass, cost/kg; per-ingredient **drift** across the runs vs the formula's proposed wt-%; and the biggest **outlier** run. `basis`: `wt_percent` (default) or `amount` |
|
|
52
54
|
| `get_batch_pivot` | Production analytics — aggregate batches by `group_by` (formula / status / month / prepared_by / project) × `metric` (count, avg_yield_pct, total_produced_kg, avg_cost_per_batch, total_samples, total_tests), with share + total for additive metrics. Optional `batch_uids` scope + `status` filter |
|
|
53
55
|
| `get_project_pivot` | Portfolio analytics — aggregate projects by `group_by` (status / phase / priority / business_unit / site / customer / lead, or `cf:<custom field>`) × `metric` (count, total_formulas / batches / samples, total_cost, avg_formulas / batches / cost_per_batch), rolling child activity + cost up by any project attribute. Optional `project_uids` scope |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.41",
|
|
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.41",
|
|
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.41",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -164,7 +164,7 @@ const list_test_results = {
|
|
|
164
164
|
const found = (t.measurements || []).some(m => {
|
|
165
165
|
if (args.parameter && !norm(m.parameter).includes(pn)) return false;
|
|
166
166
|
if (hasRange) {
|
|
167
|
-
const v = _mcpRepNumber(m); // a curve's final value, not its point count (
|
|
167
|
+
const v = _mcpRepNumber(m); // a curve's final value, not its point count (12v)
|
|
168
168
|
if (v == null) return false;
|
|
169
169
|
if (args.value_min != null && v < args.value_min) return false;
|
|
170
170
|
if (args.value_max != null && v > args.value_max) return false;
|
|
@@ -352,7 +352,7 @@ const get_doe_matrix = {
|
|
|
352
352
|
const cellParamValue = (r, col) => {
|
|
353
353
|
const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
|
|
354
354
|
if (!ms.length) return null;
|
|
355
|
-
// The app's DOE Matrix numbers (
|
|
355
|
+
// The app's DOE Matrix numbers (12v): a curve's final value, a
|
|
356
356
|
// distribution's D50, an ordinal's 1-based rank; text → its first reading.
|
|
357
357
|
const nums = ms.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
|
|
358
358
|
if (!nums.length) return ms[0].value ?? null;
|
|
@@ -407,7 +407,7 @@ const get_doe_matrix = {
|
|
|
407
407
|
// so a parameter measured 3× on one occasion counts once in failure rates and
|
|
408
408
|
// coverage, exactly as Test Analytics does.
|
|
409
409
|
const FAIL_MIN_N = 5; // app: _TA_FAIL_MIN_N — rates on fewer readings are "low n"
|
|
410
|
-
// One number per measurement — the app's flMeasNum (js/utils.js,
|
|
410
|
+
// One number per measurement — the app's flMeasNum (js/utils.js, 12v), kept
|
|
411
411
|
// in step. Never parseFloat(m.value): a saved time series / distribution has
|
|
412
412
|
// "12 points" / "8 bins" there, and an ordinal label "6B" parses to 6.
|
|
413
413
|
// scalar → value; time_series → opts.ts ('final' default | 'initial' | 'peak');
|
|
@@ -449,7 +449,7 @@ function _mcpRepNumber(m, opts) {
|
|
|
449
449
|
return isFinite(v) ? v : null;
|
|
450
450
|
}
|
|
451
451
|
// A ranked-scale (ordinal) or text (categorical) parameter's trend, as the
|
|
452
|
-
// app's Stability view draws it (FormLab
|
|
452
|
+
// app's Stability view draws it (FormLab 12w): one point per test date × run
|
|
453
453
|
// condition — an ordinal date is the MEDIAN rank of its readings (rank =
|
|
454
454
|
// 1-based position on the reading's own scale), a text date its value (or
|
|
455
455
|
// "mixed: a / b") — failing when any reading fails; one series per condition.
|
|
@@ -774,15 +774,16 @@ function _evalPassFail(m, templateId, db) {
|
|
|
774
774
|
if (!crit.requiredValue) return 'no-criteria';
|
|
775
775
|
return raw.toLowerCase() === String(crit.requiredValue).toLowerCase() ? 'pass' : 'fail';
|
|
776
776
|
}
|
|
777
|
+
// As the app (12y): one representative value against the range — a curve's
|
|
778
|
+
// FINAL point, a distribution's statistic named by the spec (crit.stat:
|
|
779
|
+
// "D90"), D50 when unnamed. It used to require every point / bin in range.
|
|
777
780
|
if (type === 'time_series' || type === 'distribution') {
|
|
778
781
|
const list = type === 'time_series' ? (Array.isArray(m.points) ? m.points : []) : (Array.isArray(m.bins) ? m.bins : []);
|
|
779
782
|
if (!list.length) return 'untested';
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
if (crit.max != null && v > crit.max) return 'fail';
|
|
785
|
-
}
|
|
783
|
+
const v = type === 'time_series' ? _mcpRepNumber(m, { ts: 'final' }) : _mcpRepNumber(m, { dist: crit.stat || 'D50' });
|
|
784
|
+
if (v == null || !isFinite(v)) return 'untested';
|
|
785
|
+
if (crit.min != null && v < crit.min) return 'fail';
|
|
786
|
+
if (crit.max != null && v > crit.max) return 'fail';
|
|
786
787
|
return 'pass';
|
|
787
788
|
}
|
|
788
789
|
return 'no-criteria';
|
|
@@ -1231,7 +1232,7 @@ const get_stability = {
|
|
|
1231
1232
|
if (!paramLc) return { error: 'parameter is required.' };
|
|
1232
1233
|
const points = [];
|
|
1233
1234
|
const skipped = { ordinal: 0, categorical: 0 };
|
|
1234
|
-
const graded = []; // ordinal / text readings, for the step / strip trend (
|
|
1235
|
+
const graded = []; // ordinal / text readings, for the step / strip trend (12w)
|
|
1235
1236
|
let specMin = null, specMax = null, unit = '';
|
|
1236
1237
|
// Each reading keeps the spec it was judged by (reading → panel row →
|
|
1237
1238
|
// method). Mirrors the app: one band only when they all agree; 2+ distinct
|
|
@@ -1244,7 +1245,7 @@ const get_stability = {
|
|
|
1244
1245
|
if (!Number.isFinite(date.getTime())) return;
|
|
1245
1246
|
(t.measurements || []).forEach(m => {
|
|
1246
1247
|
if ((m.parameter || '').trim().toLowerCase() !== paramLc) return;
|
|
1247
|
-
// As the app's Stability chart (
|
|
1248
|
+
// As the app's Stability chart (12v): a curve → its final value (not
|
|
1248
1249
|
// "12 points" → 12), and ordinal / text readings left out and counted.
|
|
1249
1250
|
const _ty = _mcpMeasType(m);
|
|
1250
1251
|
if (_ty === 'ordinal' || _ty === 'categorical') {
|
|
@@ -1374,8 +1375,219 @@ const get_stability = {
|
|
|
1374
1375
|
};
|
|
1375
1376
|
|
|
1376
1377
|
export { _mcpRepNumber, _mcpMeasType, _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
|
|
1378
|
+
// ============================================================
|
|
1379
|
+
// compare_test_results — the app's Compare Results table (js/views/testing-
|
|
1380
|
+
// compare.js), 13g. Items are formulas, batches, samples or test reports; each
|
|
1381
|
+
// gathers the reports behind it (a formula: every report on its samples). The
|
|
1382
|
+
// parameters are grouped by measurement type — numbers, curves, distributions,
|
|
1383
|
+
// ranked scales, text — and, within a group, in the test panel's order
|
|
1384
|
+
// ('method', stable) or by name ('az'), the same rule as the app (utils.js
|
|
1385
|
+
// flParamGroups). One cell per item × parameter, rolled up as the app does:
|
|
1386
|
+
// one value per test DATE (replicates on a date averaged), a curve as its final
|
|
1387
|
+
// point and a distribution as its D50 (flMeasNum), a parameter measured under
|
|
1388
|
+
// two or more run conditions split into one arm per condition (never averaged
|
|
1389
|
+
// across them), a ranked scale or text result shown as its latest reading.
|
|
1390
|
+
// ============================================================
|
|
1391
|
+
const _MCP_PARAM_GROUPS = [
|
|
1392
|
+
['scalar', 'Numbers'], ['time_series', 'Curves'], ['distribution', 'Distributions'],
|
|
1393
|
+
['ordinal', 'Ranked scales'], ['categorical', 'Text results'],
|
|
1394
|
+
];
|
|
1395
|
+
const _mcpCmp = (a, b) => String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0);
|
|
1396
|
+
function _mcpParamGroups(items, sort, db) {
|
|
1397
|
+
const sortAz = sort === 'az';
|
|
1398
|
+
const rank = new Map(_MCP_PARAM_GROUPS.map(([t], i) => [t, i]));
|
|
1399
|
+
const best = new Map(), panels = new Map(), onPanel = new Map();
|
|
1400
|
+
(items || []).forEach(it => (it.tests || []).forEach(t => (t.measurements || []).forEach(m => {
|
|
1401
|
+
const p = String((m && m.parameter) || '').trim();
|
|
1402
|
+
if (!p) return;
|
|
1403
|
+
const k = rank.get(_mcpMeasType(m)) ?? 0;
|
|
1404
|
+
if (!best.has(p) || k < best.get(p)) best.set(p, k);
|
|
1405
|
+
if (sortAz || !t.templateId) return;
|
|
1406
|
+
if (!panels.has(t.templateId)) panels.set(t.templateId, (db.templates || []).find(x => x && x.id === t.templateId) || null);
|
|
1407
|
+
const panel = panels.get(t.templateId);
|
|
1408
|
+
const params = (panel && panel.parameters) || [];
|
|
1409
|
+
const i = params.findIndex(x => x && ((m.parameterId && x.parameterId === m.parameterId) || String(x.parameter || x.name || '').trim() === p));
|
|
1410
|
+
if (i < 0) return;
|
|
1411
|
+
const at = [String(panel.name || ''), i];
|
|
1412
|
+
const cur = onPanel.get(p);
|
|
1413
|
+
if (!cur || _mcpCmp(at[0], cur[0]) < 0 || (at[0] === cur[0] && i < cur[1])) onPanel.set(p, at);
|
|
1414
|
+
})));
|
|
1415
|
+
const byMethod = (a, b) => {
|
|
1416
|
+
const x = onPanel.get(a), y = onPanel.get(b);
|
|
1417
|
+
if (x && y) return _mcpCmp(x[0], y[0]) || (x[1] - y[1]) || _mcpCmp(a, b);
|
|
1418
|
+
if (x || y) return x ? -1 : 1;
|
|
1419
|
+
return _mcpCmp(a, b);
|
|
1420
|
+
};
|
|
1421
|
+
const names = [...best.keys()];
|
|
1422
|
+
return _MCP_PARAM_GROUPS.map(([type, label], i) => ({ type, label,
|
|
1423
|
+
parameters: names.filter(p => best.get(p) === i).sort(sortAz ? _mcpCmp : byMethod) }))
|
|
1424
|
+
.filter(g => g.parameters.length);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
const _tcDate = (t) => t.testDate ? new Date(t.testDate).getTime() : (t.createdAt ? new Date(t.createdAt).getTime() : 0);
|
|
1428
|
+
const _tcIso = (ms) => { const d = new Date(ms); return Number.isFinite(d.getTime()) && ms ? d.toISOString().slice(0, 10) : ''; };
|
|
1429
|
+
|
|
1430
|
+
// One item × parameter. Mirrors _tcCellRollup / _perfParamSummaries / _tcSeriesCell.
|
|
1431
|
+
function _mcpCompareCell(item, parameter, agg, db) {
|
|
1432
|
+
const hits = [];
|
|
1433
|
+
(item.tests || []).forEach(t => (t.measurements || []).forEach(m => { if (String(m.parameter || '').trim() === parameter) hits.push({ m, t }); }));
|
|
1434
|
+
if (!hits.length) return null;
|
|
1435
|
+
hits.sort((a, b) => _tcDate(a.t) - _tcDate(b.t));
|
|
1436
|
+
const type = _mcpMeasType(hits[0].m);
|
|
1437
|
+
const arms = new Map();
|
|
1438
|
+
hits.forEach(({ m, t }) => {
|
|
1439
|
+
const v = _mcpRepNumber(m);
|
|
1440
|
+
if (v == null || !isFinite(v)) return;
|
|
1441
|
+
const sig = _mcpCondSig(m);
|
|
1442
|
+
if (!arms.has(sig)) arms.set(sig, { sig, m, t, rows: [] });
|
|
1443
|
+
arms.get(sig).rows.push({ m, t, date: _tcDate(t), value: v, pf: _evalPassFail(m, t.templateId, db) });
|
|
1444
|
+
});
|
|
1445
|
+
// Nothing numeric (a ranked scale, a text result, a distribution with no D50):
|
|
1446
|
+
// the latest reading, as typed.
|
|
1447
|
+
if (!arms.size) {
|
|
1448
|
+
const { m, t } = hits[hits.length - 1];
|
|
1449
|
+
const out = { type, value: m.value ?? null, unit: m.unit || '', pf: _evalPassFail(m, t.templateId, db), date: t.testDate || '', readings: hits.length };
|
|
1450
|
+
if (type === 'ordinal' && Array.isArray(m.scale)) { const r = _mcpRepNumber(m, { ordinal: 'rank' }); if (r != null) { out.rank = r; out.scaleLength = m.scale.length; } }
|
|
1451
|
+
if (type === 'distribution' && Array.isArray(m.bins)) { out.value = null; out.bins = m.bins.map(b => ({ label: b.label, value: b.value })); out.note = 'no D50 bin, so no single value'; }
|
|
1452
|
+
if (hits.length > 1 && (agg === 'mean' || agg === 'median' || agg === 'trend')) out.note = (out.note ? out.note + '; ' : '') + 'not a number — the latest reading is shown';
|
|
1453
|
+
return out;
|
|
1454
|
+
}
|
|
1455
|
+
const series = [...arms.values()].map(a => {
|
|
1456
|
+
const byDay = new Map();
|
|
1457
|
+
a.rows.forEach(r => { if (!byDay.has(r.date)) byDay.set(r.date, []); byDay.get(r.date).push(r); });
|
|
1458
|
+
const points = [...byDay.keys()].sort((x, y) => x - y).map(d => {
|
|
1459
|
+
const g = byDay.get(d); const vals = g.map(r => r.value);
|
|
1460
|
+
const mean = vals.reduce((x, y) => x + y, 0) / vals.length;
|
|
1461
|
+
const sd = vals.length >= 2 ? Math.sqrt(vals.reduce((x, v) => x + (v - mean) ** 2, 0) / (vals.length - 1)) : 0;
|
|
1462
|
+
const pf = g.some(r => r.pf === 'fail') ? 'fail' : g.some(r => r.pf === 'pass') ? 'pass' : g[0].pf;
|
|
1463
|
+
return { date: d, value: mean, sd, pf, replicates: g.length, m: g[0].m, t: g[0].t };
|
|
1464
|
+
});
|
|
1465
|
+
return { sig: a.sig, m: a.m, t: points[points.length - 1].t, latestM: points[points.length - 1].m, points, readings: a.rows.length };
|
|
1466
|
+
});
|
|
1467
|
+
// Primary first: the longest record, then the declared default condition, then the most recent, then by name.
|
|
1468
|
+
series.sort((a, b) => (b.points.length - a.points.length)
|
|
1469
|
+
|| (_mcpCondDefaultScore(b.latestM, b.t, db) - _mcpCondDefaultScore(a.latestM, a.t, db))
|
|
1470
|
+
|| (b.points[b.points.length - 1].date - a.points[a.points.length - 1].date) || _mcpCmp(a.sig, b.sig));
|
|
1471
|
+
const basis = type === 'time_series' ? 'final' : type === 'distribution' ? 'D50' : undefined;
|
|
1472
|
+
const one = (s) => {
|
|
1473
|
+
const lat = s.points[s.points.length - 1]; const vals = s.points.map(p => p.value);
|
|
1474
|
+
const typed = (lat.replicates === 1 && isFinite(+String(lat.m.value ?? '').trim()) && String(lat.m.value ?? '').trim() !== '' && type === 'scalar') ? +String(lat.m.value).trim() : _round(lat.value, 6);
|
|
1475
|
+
const latest = { date: _tcIso(lat.date), value: typed, pf: lat.pf, replicates: lat.replicates, ...(lat.replicates > 1 ? { sd: _round(lat.sd, 6) } : {}) };
|
|
1476
|
+
const c = { value: latest.value, unit: s.m.unit || '', ...(basis ? { basis } : {}), n: s.points.length, readings: s.readings, latest };
|
|
1477
|
+
if (agg === 'mean' && vals.length > 1) {
|
|
1478
|
+
const mean = vals.reduce((x, y) => x + y, 0) / vals.length;
|
|
1479
|
+
c.value = _round(mean, 6); c.sd = _round(Math.sqrt(vals.reduce((x, v) => x + (v - mean) ** 2, 0) / (vals.length - 1)), 6);
|
|
1480
|
+
} else if (agg === 'median' && vals.length > 1) {
|
|
1481
|
+
const so = vals.slice().sort((x, y) => x - y), k = so.length;
|
|
1482
|
+
c.value = _round(k % 2 ? so[(k - 1) / 2] : (so[k / 2 - 1] + so[k / 2]) / 2, 6); c.min = _round(so[0], 6); c.max = _round(so[k - 1], 6);
|
|
1483
|
+
} else if (agg === 'trend') {
|
|
1484
|
+
const first = vals[0], last = vals[vals.length - 1];
|
|
1485
|
+
const pct = vals.length < 2 ? 0 : first !== 0 ? (last - first) / Math.abs(first) * 100 : (last === first ? 0 : null);
|
|
1486
|
+
c.trend = { first: _round(first, 6), last: _round(last, 6), changePct: pct == null ? null : _round(pct, 2),
|
|
1487
|
+
direction: pct == null ? (last > first ? 'up' : 'down') : Math.abs(pct) < 1 ? 'flat' : (pct > 0 ? 'up' : 'down') };
|
|
1488
|
+
}
|
|
1489
|
+
return c;
|
|
1490
|
+
};
|
|
1491
|
+
const cells = series.map(one);
|
|
1492
|
+
if (series.length === 1) return { type, ...cells[0], ...(series[0].sig ? { condition: _mcpCondLabel(series[0].m) } : {}) };
|
|
1493
|
+
const tags = _mcpCondTags(series.map(s => s.sig ? _mcpCondLabel(s.m) : ''));
|
|
1494
|
+
return { type, ...cells[0], splitByCondition: true,
|
|
1495
|
+
arms: series.map((s, i) => ({ condition: s.sig ? _mcpCondLabel(s.m) : null, tag: tags[i], ...cells[i] })),
|
|
1496
|
+
note: 'measured under more than one run condition: one arm per condition, never averaged; the top-level value is the first arm (the longest record)' };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// The app's difference highlight (_tcKeysDiffer): numbers differ when their
|
|
1500
|
+
// spread is more than 5% of the largest magnitude; text on a case-insensitive match.
|
|
1501
|
+
function _mcpCellsDiffer(cells) {
|
|
1502
|
+
const live = cells.filter(c => c && (c.value != null || c.trend));
|
|
1503
|
+
if (live.length < 2) return false;
|
|
1504
|
+
const dirs = live.map(c => (c.trend && c.trend.direction) || '');
|
|
1505
|
+
if (dirs.some(d => d !== dirs[0])) return true;
|
|
1506
|
+
const nums = live.map(c => (typeof c.value === 'number' ? c.value : NaN));
|
|
1507
|
+
if (nums.every(Number.isFinite)) {
|
|
1508
|
+
const lo = Math.min(...nums), hi = Math.max(...nums);
|
|
1509
|
+
return hi !== lo && (hi - lo) / Math.max(Math.abs(lo), Math.abs(hi)) > 0.05;
|
|
1510
|
+
}
|
|
1511
|
+
const low = live.map(c => String(c.value ?? '').trim().toLowerCase());
|
|
1512
|
+
return low.some(k => k !== low[0]);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const compare_test_results = {
|
|
1516
|
+
definition: {
|
|
1517
|
+
name: 'compare_test_results',
|
|
1518
|
+
description: 'Compare TEST RESULTS side by side across 2+ formulas, batches, samples or test reports — the app\'s Compare Results table. Each item gathers the reports behind it (a formula: every report on its samples; a batch: reports on samples drawn from it). Parameters come grouped by measurement type (Numbers, Curves, Distributions, Ranked scales, Text results) and, within a group, in the test panel\'s order (order "method", default) or A–Z ("az"). One cell per item × parameter: aggregation "latest" (default) = the most recent test date (replicates on one date averaged, ± sd), "mean" = mean of the per-date values ± sd between dates, "median" = median with min–max, "trend" = latest with the change from the first date. A curve is compared at its final point and a distribution at its D50; a ranked scale or text result shows its latest reading (with its rank on the scale). A parameter measured under 2+ run conditions (e.g. storage 25 °C vs 40 °C) is split into arms, never averaged across conditions. `differs` flags a parameter whose values spread by more than 5% across items, as the app highlights. For composition differences use compare_formulations; for production runs, compare_batches.',
|
|
1519
|
+
inputSchema: {
|
|
1520
|
+
type: 'object',
|
|
1521
|
+
properties: {
|
|
1522
|
+
mode: { type: 'string', enum: ['formulations', 'batches', 'samples', 'tests'], description: 'What the ids are. Default formulations.' },
|
|
1523
|
+
ids: { type: 'array', items: { type: 'string' }, description: '2–20 ids or UIDs (FORM-…, BATCH-…, SAMP-…, TEST-…); a formula may also be given by its exact name.' },
|
|
1524
|
+
aggregation: { type: 'string', enum: ['latest', 'mean', 'median', 'trend'], description: 'How each cell rolls up several test dates. Default latest. Ignored in tests mode (one report per item).' },
|
|
1525
|
+
order: { type: 'string', enum: ['method', 'az'], description: 'Parameter order within each type group: the test panel\'s order (default) or A–Z.' },
|
|
1526
|
+
parameter_filter: { type: 'string', description: 'Only parameters whose name contains this (case-insensitive).' },
|
|
1527
|
+
},
|
|
1528
|
+
required: ['ids'],
|
|
1529
|
+
},
|
|
1530
|
+
},
|
|
1531
|
+
handler: async (args) => {
|
|
1532
|
+
const { db } = getStore();
|
|
1533
|
+
const mode = ['formulations', 'batches', 'samples', 'tests'].includes(args.mode) ? args.mode : 'formulations';
|
|
1534
|
+
const agg = mode === 'tests' ? 'latest' : (['latest', 'mean', 'median', 'trend'].includes(args.aggregation) ? args.aggregation : 'latest');
|
|
1535
|
+
const order = args.order === 'az' ? 'az' : 'method';
|
|
1536
|
+
if (!Array.isArray(args.ids) || args.ids.length < 2) return { error: 'Pass at least 2 ids.' };
|
|
1537
|
+
if (args.ids.length > 20) return { error: 'Compare at most 20 items at once.' };
|
|
1538
|
+
const coll = { formulations: 'formulations', batches: 'batches', samples: 'samples', tests: 'testResults' }[mode];
|
|
1539
|
+
const lc = (s) => String(s || '').trim().toLowerCase();
|
|
1540
|
+
const resolve = (id) => resolveById(coll, id) || (mode === 'formulations' ? (db.formulations || []).find(f => f && lc(f.name) === lc(id)) : null);
|
|
1541
|
+
const recs = []; const missing = [];
|
|
1542
|
+
args.ids.forEach(id => { const r = resolve(id); if (!r) missing.push(id); else if (!recs.includes(r)) recs.push(r); });
|
|
1543
|
+
if (recs.length < 2) return { error: `Fewer than 2 of the ids resolved to ${mode}.`, missing };
|
|
1544
|
+
const samplesById = new Map((db.samples || []).map(s => [s.id, s]));
|
|
1545
|
+
const formOf = (s) => s && s.formulationId ? (db.formulations || []).find(f => f.id === s.formulationId) : null;
|
|
1546
|
+
const pf = lc(args.parameter_filter);
|
|
1547
|
+
const narrow = (t) => pf ? { ...t, measurements: (t.measurements || []).filter(m => lc(m.parameter).includes(pf)) } : t;
|
|
1548
|
+
const items = recs.map(r => {
|
|
1549
|
+
const tests = (mode === 'tests' ? [r] : (db.testResults || []).filter(t => {
|
|
1550
|
+
const s = samplesById.get(t.sampleId);
|
|
1551
|
+
return mode === 'samples' ? t.sampleId === r.id : mode === 'batches' ? !!(s && s.batchId === r.id) : !!(s && s.formulationId === r.id);
|
|
1552
|
+
})).map(narrow);
|
|
1553
|
+
const days = tests.map(t => t.testDate).filter(Boolean).sort();
|
|
1554
|
+
const smp = mode === 'tests' ? samplesById.get(r.sampleId) : mode === 'samples' ? r : null;
|
|
1555
|
+
const form = mode === 'formulations' ? r : mode === 'batches' ? (db.formulations || []).find(f => f.id === r.formulationId) : formOf(smp);
|
|
1556
|
+
return { rec: r, tests, head: {
|
|
1557
|
+
id: r.id, uid: r.uid || null,
|
|
1558
|
+
name: mode === 'formulations' ? (r.name || '') : mode === 'tests' ? (r.reportName || r.uid || '') : (r.uid || r.id),
|
|
1559
|
+
...(mode !== 'formulations' && form ? { formula: form.uid || form.name } : {}),
|
|
1560
|
+
tests: tests.length, samples: new Set(tests.map(t => t.sampleId).filter(Boolean)).size,
|
|
1561
|
+
dateRange: days.length ? (days[0] === days[days.length - 1] ? days[0] : `${days[0]} … ${days[days.length - 1]}`) : null,
|
|
1562
|
+
} };
|
|
1563
|
+
});
|
|
1564
|
+
const keyOf = (it) => it.head.uid || it.head.id;
|
|
1565
|
+
const groups = _mcpParamGroups(items, order, db).map(g => ({
|
|
1566
|
+
type: g.type, label: g.label,
|
|
1567
|
+
parameters: g.parameters.map(p => {
|
|
1568
|
+
const cells = items.map(it => _mcpCompareCell(it, p, agg, db));
|
|
1569
|
+
const unit = (cells.find(c => c && c.unit) || {}).unit || '';
|
|
1570
|
+
return { parameter: p, ...(unit ? { unit } : {}), differs: _mcpCellsDiffer(cells),
|
|
1571
|
+
missingFrom: items.filter((_, i) => !cells[i]).map(keyOf),
|
|
1572
|
+
values: Object.fromEntries(items.map((it, i) => [keyOf(it), cells[i]])) };
|
|
1573
|
+
}),
|
|
1574
|
+
}));
|
|
1575
|
+
const paramCount = groups.reduce((n, g) => n + g.parameters.length, 0);
|
|
1576
|
+
return {
|
|
1577
|
+
mode, aggregation: agg, order,
|
|
1578
|
+
items: items.map(it => it.head),
|
|
1579
|
+
...(missing.length ? { missing } : {}),
|
|
1580
|
+
parameterCount: paramCount,
|
|
1581
|
+
differingCount: groups.reduce((n, g) => n + g.parameters.filter(p => p.differs).length, 0),
|
|
1582
|
+
groups,
|
|
1583
|
+
note: 'values are keyed by each item\'s uid (or id). n = test dates behind a value; readings = every reading. A pass/fail (pf) is the latest date\'s verdict: a date fails when any of its readings fails.',
|
|
1584
|
+
};
|
|
1585
|
+
},
|
|
1586
|
+
};
|
|
1587
|
+
|
|
1377
1588
|
export const tools = {
|
|
1378
1589
|
list_test_results,
|
|
1590
|
+
compare_test_results,
|
|
1379
1591
|
get_test_result,
|
|
1380
1592
|
get_doe_matrix,
|
|
1381
1593
|
find_failures,
|
package/tools/designspace.js
CHANGED
|
@@ -346,7 +346,7 @@ 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 (
|
|
349
|
+
// _asmFormulaValueForCol (12v): readings of the primary run condition (the one
|
|
350
350
|
// with the most readings), each as the DOE Matrix reads it (a curve's final
|
|
351
351
|
// value, a distribution's D50, an ordinal's rank), then their mean.
|
|
352
352
|
function _paramValue(f, param, db) {
|