formlab-mcp 0.5.1 → 0.6.0
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 +4 -2
- package/index.js +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +216 -4
package/README.md
CHANGED
|
@@ -40,10 +40,12 @@ Ask Claude (or another MCP client) things like:
|
|
|
40
40
|
| `list_samples` | Filtered list of physical specimens |
|
|
41
41
|
| `get_sample` | Full record + canonical variant + test reports + blend lineage |
|
|
42
42
|
| `list_test_results` | Filtered list of test reports (by sample, parameter, **measured-value range** (`value_min`/`value_max`), date, or lab) |
|
|
43
|
-
| `get_test_result` | Full
|
|
43
|
+
| `get_test_result` | Full report: every measurement's value, spec, and the **resolved instrument** (`instrumentSource`: row / method / run) |
|
|
44
44
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
45
45
|
| `find_failures` | Pareto-style: parameters that fail acceptance most often |
|
|
46
46
|
| `get_coverage_matrix` | Which formulations × parameters have been measured |
|
|
47
|
+
| `list_doe_designs` | Saved DOE designs — the *recipe* behind each batch of runs: design type, factors + ranges, constraints, run count, D-efficiency. Filters: `project`, `design_type`, `constrained_only`, `name_contains` |
|
|
48
|
+
| `get_doe_design` | One design in full: constraints in plain language, the model it was optimised for, generation settings (run budget, replicates, centre points, seed) and the complete run matrix with the formulation each run became |
|
|
47
49
|
| `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES. Requires `@rdkit/rdkit` (optional dependency — install with `npm install @rdkit/rdkit` in `mcp/` if you get a "not installed" error). Examples: `c1ccccc1` (any aromatic 6-ring), `[OX2H1]` (any hydroxyl), `C(=O)O` (carboxylic acid), `[F,Cl,Br,I]` (any halogen). |
|
|
48
50
|
| `list_equipment` | Filtered list of the Equipment registry (mixers, ovens, viscometers, balances…). Filters: `category`, `status`, `manufacturer`, `name_contains` |
|
|
49
51
|
| `get_equipment` | Full equipment record + a summary of where it's used (panels, step presets, test methods, formulas, batches) |
|
|
@@ -141,7 +143,7 @@ Or for local-dev (from the repo):
|
|
|
141
143
|
}
|
|
142
144
|
```
|
|
143
145
|
|
|
144
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
146
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 32 tools become callable in any conversation.
|
|
145
147
|
|
|
146
148
|
## Wire it up to Claude Code
|
|
147
149
|
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// FORMLAB_EXPORT=/path/to/export.json node index.js
|
|
8
8
|
// formlab-mcp /path/to/export.json (when installed via npm)
|
|
9
9
|
//
|
|
10
|
-
// Exposes
|
|
10
|
+
// Exposes 32 tools (see ./tools/) over stdio. The MCP host (Claude
|
|
11
11
|
// Desktop, Claude Code, etc.) handles tool discovery, invocation
|
|
12
12
|
// and response formatting. We just register handlers and stay out
|
|
13
13
|
// of the way.
|
|
@@ -114,7 +114,7 @@ const TOOLS = [
|
|
|
114
114
|
];
|
|
115
115
|
|
|
116
116
|
const server = new Server(
|
|
117
|
-
{ name: 'formlab-mcp', version: '0.
|
|
117
|
+
{ name: 'formlab-mcp', version: '0.6.0' },
|
|
118
118
|
{ capabilities: { tools: {} } }
|
|
119
119
|
);
|
|
120
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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.5.
|
|
5
|
+
"version": "0.5.2",
|
|
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.5.
|
|
16
|
+
"version": "0.5.2",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -5,10 +5,40 @@
|
|
|
5
5
|
// - get_doe_matrix (the same shape the DOE Matrix UI produces)
|
|
6
6
|
// - find_failures (Pareto-style: which params fail most)
|
|
7
7
|
// - get_coverage_matrix (which items × parameters have been tested)
|
|
8
|
+
// - list_doe_designs (saved design records — the intent behind the runs)
|
|
9
|
+
// - get_doe_design (one design in full, incl. its run matrix)
|
|
8
10
|
// ============================================================
|
|
9
11
|
|
|
10
12
|
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
11
13
|
|
|
14
|
+
// Which instrument produced a measurement. Mirrors the app's _trMeasInstrument:
|
|
15
|
+
// a measurement's raw `instrument` is usually EMPTY because blank means inherit,
|
|
16
|
+
// so returning the stored field alone would be near-useless here. Resolve the
|
|
17
|
+
// same cascade the UI shows, and report the source so a consumer can tell a
|
|
18
|
+
// recorded fact from an inherited default:
|
|
19
|
+
// 'row' — the measurement names its own instrument
|
|
20
|
+
// 'method' — from the parameter's Test Method equipment (the usual case)
|
|
21
|
+
// 'run' — the report-level default (an assumption, not evidence)
|
|
22
|
+
function _resolveInstrument(m, t, db) {
|
|
23
|
+
if (m && (m.instrument || m.equipmentId)) {
|
|
24
|
+
return { instrument: m.instrument || '', equipmentId: m.equipmentId || null, instrumentSource: 'row' };
|
|
25
|
+
}
|
|
26
|
+
const params = (db && db.parameters) || [];
|
|
27
|
+
let pm = null;
|
|
28
|
+
if (m && m.parameterId) pm = params.find(p => p && p.id === m.parameterId) || null;
|
|
29
|
+
if (!pm && m) {
|
|
30
|
+
const nm = String(m.parameter || '').trim().toLowerCase();
|
|
31
|
+
if (nm) pm = params.find(p => p && String(p.name || '').trim().toLowerCase() === nm) || null;
|
|
32
|
+
}
|
|
33
|
+
if (pm && (pm.equipment || pm.equipmentId)) {
|
|
34
|
+
return { instrument: pm.equipment || '', equipmentId: pm.equipmentId || null, instrumentSource: 'method' };
|
|
35
|
+
}
|
|
36
|
+
if (t && (t.instrument || t.equipmentId)) {
|
|
37
|
+
return { instrument: t.instrument || '', equipmentId: t.equipmentId || null, instrumentSource: 'run' };
|
|
38
|
+
}
|
|
39
|
+
return { instrument: '', equipmentId: null, instrumentSource: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
12
42
|
function _trimTest(t, samplesById) {
|
|
13
43
|
if (!t) return null;
|
|
14
44
|
const s = t.sampleId ? samplesById.get(t.sampleId) : null;
|
|
@@ -20,12 +50,16 @@ function _trimTest(t, samplesById) {
|
|
|
20
50
|
testDate: t.testDate || '',
|
|
21
51
|
performedBy: t.performedBy || '',
|
|
22
52
|
lab: t.lab || '',
|
|
53
|
+
instrument: t.instrument || '', // run-level default; measurements may override
|
|
54
|
+
equipmentId: t.equipmentId || null,
|
|
55
|
+
controlSample: !!t.controlSample, // a re-measured reference — the valid basis for drift
|
|
23
56
|
measurementCount: (t.measurements || []).length,
|
|
24
57
|
parameters: [...new Set((t.measurements || []).map(m => m.parameter).filter(Boolean))],
|
|
25
58
|
};
|
|
26
59
|
}
|
|
27
60
|
|
|
28
|
-
function _trimMeasurement(m) {
|
|
61
|
+
function _trimMeasurement(m, t, db) {
|
|
62
|
+
const ins = _resolveInstrument(m, t, db);
|
|
29
63
|
return {
|
|
30
64
|
parameter: m.parameter || '',
|
|
31
65
|
value: m.value ?? null,
|
|
@@ -34,13 +68,14 @@ function _trimMeasurement(m) {
|
|
|
34
68
|
pointCount: Array.isArray(m.points) ? m.points.length : 0,
|
|
35
69
|
binCount: Array.isArray(m.bins) ? m.bins.length : 0,
|
|
36
70
|
acceptanceCriteria: m.acceptanceCriteria || null,
|
|
71
|
+
...ins, // instrument, equipmentId, instrumentSource
|
|
37
72
|
};
|
|
38
73
|
}
|
|
39
74
|
|
|
40
75
|
const list_test_results = {
|
|
41
76
|
definition: {
|
|
42
77
|
name: 'list_test_results',
|
|
43
|
-
description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). Returns metadata + parameters tested; use get_test_result for
|
|
78
|
+
description: 'List test result reports, optionally filtered by sample, parameter, measured-value range, date range, or lab. Combine parameter + value_min/value_max to find reports whose measurement is in range (the "find by results" query, e.g. parameter="Viscosity", value_min=800, value_max=3000). Returns metadata (incl. the run-level instrument and controlSample flag) + parameters tested; use get_test_result for per-measurement values and their resolved instruments.',
|
|
44
79
|
inputSchema: {
|
|
45
80
|
type: 'object',
|
|
46
81
|
properties: {
|
|
@@ -99,7 +134,7 @@ const list_test_results = {
|
|
|
99
134
|
const get_test_result = {
|
|
100
135
|
definition: {
|
|
101
136
|
name: 'get_test_result',
|
|
102
|
-
description: 'Get full details for a single test result report
|
|
137
|
+
description: 'Get full details for a single test result report: every measurement\'s value, unit, type, acceptance criteria, and the INSTRUMENT that produced it. instrument is RESOLVED (measurement override → the parameter\'s Test Method equipment → the report default) with instrumentSource telling you which — treat source "run" as an inherited assumption, not evidence. Accepts internal id or UID.',
|
|
103
138
|
inputSchema: {
|
|
104
139
|
type: 'object',
|
|
105
140
|
properties: {
|
|
@@ -115,7 +150,7 @@ const get_test_result = {
|
|
|
115
150
|
return {
|
|
116
151
|
..._trimTest(t, indexes.samplesById),
|
|
117
152
|
notes: t.notes || '',
|
|
118
|
-
measurements: (t.measurements || []).map(_trimMeasurement),
|
|
153
|
+
measurements: (t.measurements || []).map(m => _trimMeasurement(m, t, getStore().db)),
|
|
119
154
|
};
|
|
120
155
|
},
|
|
121
156
|
};
|
|
@@ -430,10 +465,187 @@ const get_coverage_matrix = {
|
|
|
430
465
|
},
|
|
431
466
|
};
|
|
432
467
|
|
|
468
|
+
// ============================================================
|
|
469
|
+
// SAVED DOE DESIGNS
|
|
470
|
+
// ------------------------------------------------------------
|
|
471
|
+
// get_doe_matrix reconstructs an EXECUTED design after the fact from
|
|
472
|
+
// formulations + tests. These two read the design RECORD instead: the factors
|
|
473
|
+
// and their ranges, the constraints, the model the runs were optimised for, the
|
|
474
|
+
// run budget, the seed and the resulting D-efficiency — the intent behind the
|
|
475
|
+
// runs, which cannot be recovered from the runs themselves.
|
|
476
|
+
// ============================================================
|
|
477
|
+
|
|
478
|
+
// Human-readable constraint, e.g. "Water + Glycerin <= 40". Mirrors the app's
|
|
479
|
+
// _asmDoeConstraintLabel, including its ratio special case, so the MCP never
|
|
480
|
+
// describes a rule differently from the UI.
|
|
481
|
+
function _doeConstraintText(c, nameOf) {
|
|
482
|
+
if (c && c.ratioGroup && c.ratioRole && (c.terms || []).length === 2) {
|
|
483
|
+
const bound = -(+c.terms[1].coef || 0);
|
|
484
|
+
return `${nameOf(c.terms[0].id)} : ${nameOf(c.terms[1].id)} ${c.ratioRole === 'lo' ? 'at least' : 'at most'} ${+bound.toFixed(6)}:1`;
|
|
485
|
+
}
|
|
486
|
+
const parts = ((c && c.terms) || []).map((t, i) => {
|
|
487
|
+
const coef = +t.coef || 0;
|
|
488
|
+
const mag = Math.abs(coef);
|
|
489
|
+
return `${coef < 0 ? '- ' : (i ? '+ ' : '')}${mag === 1 ? '' : +mag.toFixed(6) + '*'}${nameOf(t.id)}`;
|
|
490
|
+
});
|
|
491
|
+
const op = c && c.op === '>=' ? '>=' : c && c.op === '==' ? '=' : '<=';
|
|
492
|
+
return `${parts.join(' ')} ${op} ${+(+(c && c.rhs) || 0).toFixed(6)}`;
|
|
493
|
+
}
|
|
494
|
+
const _DOE_MODEL_LABELS = {
|
|
495
|
+
linear: 'Linear (main effects)',
|
|
496
|
+
quadratic: 'Quadratic',
|
|
497
|
+
'quadratic-int': 'Quadratic + interactions (full response surface)',
|
|
498
|
+
'scheffe-linear': 'Scheffé linear (mixture — no intercept)',
|
|
499
|
+
'scheffe-quadratic': 'Scheffé quadratic (mixture — adds two-way blending terms)',
|
|
500
|
+
'scheffe-cubic': 'Scheffé special cubic (mixture — adds three-way blending terms)',
|
|
501
|
+
};
|
|
502
|
+
const _DOE_TYPE_LABELS = {
|
|
503
|
+
full: 'Full-factorial', pb: 'Plackett-Burman', ccd: 'Central Composite',
|
|
504
|
+
bbd: 'Box-Behnken', lhs: 'Latin Hypercube', simplex: 'Simplex-Lattice', dopt: 'D-optimal',
|
|
505
|
+
};
|
|
506
|
+
// Factor names come off the RECORD first so a design still reads correctly
|
|
507
|
+
// after an ingredient is renamed or deleted.
|
|
508
|
+
function _doeNamer(d, db) {
|
|
509
|
+
const byId = new Map((d.factors || []).map(f => [f.ingredientId, f.name]));
|
|
510
|
+
return (id) => byId.get(id)
|
|
511
|
+
|| (((db.ingredients || []).find(i => i.id === id) || {}).name)
|
|
512
|
+
|| id || '?';
|
|
513
|
+
}
|
|
514
|
+
function _trimDoeDesign(d, db) {
|
|
515
|
+
const nameOf = _doeNamer(d, db);
|
|
516
|
+
const proj = d.projectId ? (db.projects || []).find(p => p.id === d.projectId) : null;
|
|
517
|
+
return {
|
|
518
|
+
id: d.id,
|
|
519
|
+
uid: d.uid,
|
|
520
|
+
name: d.name,
|
|
521
|
+
designType: d.designType,
|
|
522
|
+
designTypeLabel: _DOE_TYPE_LABELS[d.designType] || d.designType,
|
|
523
|
+
project: proj ? proj.name : null,
|
|
524
|
+
runCount: d.runCount || 0,
|
|
525
|
+
factorCount: (d.factors || []).length,
|
|
526
|
+
factors: (d.factors || []).map(f => ({ ingredient: f.name || nameOf(f.ingredientId), low: f.low, high: f.high, unit: f.unit || '%' })),
|
|
527
|
+
constraints: (d.constraints || []).map(c => _doeConstraintText(c, nameOf)),
|
|
528
|
+
mixtureMode: !!d.mixtureMode,
|
|
529
|
+
// Only D-optimal computes one; null here means "not applicable", not "failed".
|
|
530
|
+
dEfficiency: Number.isFinite(d.dEfficiency) ? +d.dEfficiency.toFixed(2) : null,
|
|
531
|
+
droppedRuns: d.droppedRuns || 0,
|
|
532
|
+
response: d.responseText || null,
|
|
533
|
+
createdAt: d.createdAt,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const list_doe_designs = {
|
|
538
|
+
definition: {
|
|
539
|
+
name: 'list_doe_designs',
|
|
540
|
+
description: 'List saved DOE designs — the recipe behind each generated batch of runs: design type, factors and their ranges, constraints, run count and D-efficiency. This is the design INTENT; use get_doe_matrix for the executed runs and their measured results. Use get_doe_design for the full run matrix of one design.',
|
|
541
|
+
inputSchema: {
|
|
542
|
+
type: 'object',
|
|
543
|
+
properties: {
|
|
544
|
+
project: { type: 'string', description: 'Filter by project name or id.' },
|
|
545
|
+
design_type: { type: 'string', description: 'Filter by design type: full, pb, ccd, bbd, lhs, simplex, dopt.' },
|
|
546
|
+
constrained_only: { type: 'boolean', description: 'Only designs that carry linear constraints.' },
|
|
547
|
+
name_contains: { type: 'string', description: 'Case-insensitive substring match on design name.' },
|
|
548
|
+
limit: { type: 'number', description: 'Max rows to return (default 50, max 500).' },
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
handler: async (args) => {
|
|
553
|
+
const { db, indexes } = getStore();
|
|
554
|
+
const limit = Math.min(500, Math.max(1, args.limit || 50));
|
|
555
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
556
|
+
const all = (db.doeDesigns || []).filter(d => d && !d._trashed);
|
|
557
|
+
const filtered = all.filter(d => {
|
|
558
|
+
if (args.design_type && norm(d.designType) !== norm(args.design_type)) return false;
|
|
559
|
+
if (args.constrained_only && !(d.constraints || []).length) return false;
|
|
560
|
+
if (args.name_contains && !norm(d.name).includes(norm(args.name_contains))) return false;
|
|
561
|
+
if (args.project) {
|
|
562
|
+
const p = indexes.projectsById.get(args.project)
|
|
563
|
+
|| (db.projects || []).find(x => norm(x.name) === norm(args.project));
|
|
564
|
+
if (!p || d.projectId !== p.id) return false;
|
|
565
|
+
}
|
|
566
|
+
return true;
|
|
567
|
+
}).sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
568
|
+
return {
|
|
569
|
+
totalMatching: filtered.length,
|
|
570
|
+
returned: Math.min(filtered.length, limit),
|
|
571
|
+
doeDesigns: filtered.slice(0, limit).map(d => _trimDoeDesign(d, db)),
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const get_doe_design = {
|
|
577
|
+
definition: {
|
|
578
|
+
name: 'get_doe_design',
|
|
579
|
+
description: 'Get one saved DOE design in full: factors and ranges, constraints, the model it was optimised for, generation settings (run budget, replicates, centre points, seed), D-efficiency, and the complete run matrix with the formulation each run became. Everything needed to reproduce or audit the design.',
|
|
580
|
+
inputSchema: {
|
|
581
|
+
type: 'object',
|
|
582
|
+
properties: {
|
|
583
|
+
design: { type: 'string', description: 'Design id or UID (e.g. "DOE-0001").' },
|
|
584
|
+
include_runs: { type: 'boolean', description: 'Include the full run matrix. Default true.' },
|
|
585
|
+
},
|
|
586
|
+
required: ['design'],
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
handler: async (args) => {
|
|
590
|
+
const { db } = getStore();
|
|
591
|
+
const d = resolveById('doeDesigns', args.design);
|
|
592
|
+
if (!d) return { error: `No DOE design matched "${args.design}".` };
|
|
593
|
+
const nameOf = _doeNamer(d, db);
|
|
594
|
+
const base = _trimDoeDesign(d, db);
|
|
595
|
+
const st = d.settings || {};
|
|
596
|
+
// Only report the settings that actually apply to this design type —
|
|
597
|
+
// an axial distance on a Plackett-Burman would be noise presented as fact.
|
|
598
|
+
const settings = { replicates: st.replicates };
|
|
599
|
+
if (d.designType === 'dopt') {
|
|
600
|
+
settings.runBudget = st.doptRuns;
|
|
601
|
+
settings.modelOptimisedFor = _DOE_MODEL_LABELS[st.doptModel] || st.doptModel;
|
|
602
|
+
// A mixture design is modelled on the simplex, which changes what the
|
|
603
|
+
// coefficients mean — worth stating rather than leaving to be inferred
|
|
604
|
+
// from the model name.
|
|
605
|
+
if (/^scheffe-/.test(String(st.doptModel || ''))) {
|
|
606
|
+
settings.mixtureNote = 'Scheffé (mixture) model: no intercept and no pure squares, because components summing to a constant make those terms inestimable. Coefficients read as blend effects, not factor effects.';
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (d.designType === 'lhs') { settings.runBudget = st.nRuns; settings.randomSeed = st.seed; }
|
|
610
|
+
if (d.designType === 'ccd') { settings.centerPoints = st.centerPoints; settings.axialDistance = st.alpha; }
|
|
611
|
+
if (d.designType === 'bbd') settings.centerPoints = st.centerPoints;
|
|
612
|
+
if (d.designType === 'simplex') settings.latticeDegree = st.simplexDegree;
|
|
613
|
+
|
|
614
|
+
const formsById = new Map((db.formulations || []).map(f => [f.id, f]));
|
|
615
|
+
const linked = (d.formulationIds || []).map(id => formsById.get(id)).filter(Boolean);
|
|
616
|
+
const out = {
|
|
617
|
+
...base,
|
|
618
|
+
balanceFactor: d.mixtureMode ? nameOf(d.balanceFactorId) : null,
|
|
619
|
+
settings,
|
|
620
|
+
augmentedFromProjectId: d.augmentedFromProjectId || null,
|
|
621
|
+
formulationsGenerated: (d.formulationIds || []).length,
|
|
622
|
+
formulationsStillPresent: linked.length,
|
|
623
|
+
notes: d.notes || null,
|
|
624
|
+
};
|
|
625
|
+
if (Number.isFinite(d.dEfficiency)) {
|
|
626
|
+
out.dEfficiencyNote = 'D-efficiency compares designs fitting the SAME model at the SAME run count. It is a relative score, not a pass mark, and cannot be compared across different models.';
|
|
627
|
+
}
|
|
628
|
+
if (args.include_runs !== false) {
|
|
629
|
+
const facs = d.factors || [];
|
|
630
|
+
out.factorOrder = facs.map(f => f.name || nameOf(f.ingredientId));
|
|
631
|
+
out.runs = (d.runs || []).map((r, i) => {
|
|
632
|
+
const row = { run: i + 1 };
|
|
633
|
+
facs.forEach(f => { row[f.name || nameOf(f.ingredientId)] = r[f.ingredientId]; });
|
|
634
|
+
const lf = formsById.get((d.formulationIds || [])[i]);
|
|
635
|
+
row.formulation = lf ? (lf.name || lf.uid) : null;
|
|
636
|
+
return row;
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return out;
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
|
|
433
643
|
export const tools = {
|
|
434
644
|
list_test_results,
|
|
435
645
|
get_test_result,
|
|
436
646
|
get_doe_matrix,
|
|
437
647
|
find_failures,
|
|
438
648
|
get_coverage_matrix,
|
|
649
|
+
list_doe_designs,
|
|
650
|
+
get_doe_design,
|
|
439
651
|
};
|