formlab-mcp 0.6.3 → 0.6.5
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/SUBMISSION.md +4 -2
- package/index.js +3 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +8 -4
- package/tools/designspace.js +448 -0
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
19
19
|
- _"What parameters fail most often in Q2 testing?"_
|
|
20
20
|
- _"Show me the DOE matrix at sample grain for all 'Anti-aging serum' family formulas."_
|
|
21
21
|
- _"What's untested? Which of my approved formulas have no measurements yet?"_
|
|
22
|
+
- _"Map the design space of my WallPaint project — which ingredients vary, and what combination haven't I tried yet?"_
|
|
22
23
|
- _"List all peptide ingredients with purity above 95%."_
|
|
23
24
|
- _"Which botanicals do I source from Madagascar?"_
|
|
24
25
|
- _"Find ingredients where the sequence contains KTTKS."_
|
|
@@ -43,7 +44,8 @@ Ask Claude (or another MCP client) things like:
|
|
|
43
44
|
| `get_test_result` | Full report: every measurement's value, spec, and the **resolved instrument** (`instrumentSource`: row / method / run) |
|
|
44
45
|
| `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
|
|
45
46
|
| `find_failures` | Pareto-style: parameters that fail acceptance most often |
|
|
46
|
-
| `get_coverage_matrix` | Which formulations × parameters have been measured |
|
|
47
|
+
| `get_coverage_matrix` | Which formulations × parameters have been measured (TEST coverage) |
|
|
48
|
+
| `get_design_space` | COMPOSITION design space for one project: which ingredients vary and over what observed wt-% ranges (ranked), an occupied-region summary ("where you've been"), the biggest untested **interior gap** as ready-to-seed DOE ranges (a combination you could have made but skipped), and an optional standardised **2-component PCA** of all varying ingredients — loadings, variance-explained, scree, and a full-dimensional gap. Matches the in-app Design Space Viewer exactly. Answers *"where haven't I explored?"* / *"what should I formulate next?"* |
|
|
47
49
|
| `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
50
|
| `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 |
|
|
49
51
|
| `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). |
|
|
@@ -143,7 +145,7 @@ Or for local-dev (from the repo):
|
|
|
143
145
|
}
|
|
144
146
|
```
|
|
145
147
|
|
|
146
|
-
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's
|
|
148
|
+
Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 33 tools become callable in any conversation.
|
|
147
149
|
|
|
148
150
|
## Wire it up to Claude Code
|
|
149
151
|
|
package/SUBMISSION.md
CHANGED
|
@@ -47,8 +47,10 @@ _"what parameters fail acceptance most often?"_, or _"show me the DOE matrix
|
|
|
47
47
|
at sample grain for Anti-Aging Serum family"_ — all against a JSON export
|
|
48
48
|
that stays on the user's laptop.
|
|
49
49
|
|
|
50
|
-
**
|
|
51
|
-
test results, DOE matrices, similarity, failure analysis,
|
|
50
|
+
**33 read-only tools** covering formulations, ingredients, batches, samples,
|
|
51
|
+
test results, DOE matrices + saved designs, similarity, failure analysis,
|
|
52
|
+
test-coverage AND composition design-space mapping (varying ingredients,
|
|
53
|
+
untested gaps, PCA), equipment, test methods, panels, projects and ELN notes.
|
|
52
54
|
|
|
53
55
|
- Repo: https://github.com/juliu1980/FormLab/tree/main/mcp
|
|
54
56
|
- npm: https://www.npmjs.com/package/formlab-mcp
|
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 33 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.
|
|
@@ -30,6 +30,7 @@ import * as formulations from './tools/formulations.js';
|
|
|
30
30
|
import * as ingredients from './tools/ingredients.js';
|
|
31
31
|
import * as lab from './tools/lab.js';
|
|
32
32
|
import * as analytics from './tools/analytics.js';
|
|
33
|
+
import * as designspace from './tools/designspace.js';
|
|
33
34
|
import * as library from './tools/library.js';
|
|
34
35
|
import * as projects from './tools/projects.js';
|
|
35
36
|
import * as notebook from './tools/notebook.js';
|
|
@@ -108,6 +109,7 @@ const TOOLS = [
|
|
|
108
109
|
...Object.values(ingredients.tools),
|
|
109
110
|
...Object.values(lab.tools),
|
|
110
111
|
...Object.values(analytics.tools),
|
|
112
|
+
...Object.values(designspace.tools),
|
|
111
113
|
...Object.values(library.tools),
|
|
112
114
|
...Object.values(projects.tools),
|
|
113
115
|
...Object.values(notebook.tools),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
5
|
"description": "Read-only Model Context Protocol server for FormLab — 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.6.5",
|
|
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.6.5",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -508,12 +508,16 @@ const _DOE_TYPE_LABELS = {
|
|
|
508
508
|
function _doeModelKey(d) {
|
|
509
509
|
return d && d.designType === 'dopt' ? ((d.settings || {}).doptModel || null) : null;
|
|
510
510
|
}
|
|
511
|
+
// A DOE factor is keyed by ingredientId, or by formulationId when it's a
|
|
512
|
+
// sub-formula dosed as a base. Both factors[] and runs[] use that id.
|
|
513
|
+
function _doeFactorId(f) { return f.ingredientId || f.formulationId; }
|
|
511
514
|
// Factor names come off the RECORD first so a design still reads correctly
|
|
512
515
|
// after an ingredient is renamed or deleted.
|
|
513
516
|
function _doeNamer(d, db) {
|
|
514
|
-
const byId = new Map((d.factors || []).map(f => [f
|
|
517
|
+
const byId = new Map((d.factors || []).map(f => [_doeFactorId(f), f.name]));
|
|
515
518
|
return (id) => byId.get(id)
|
|
516
519
|
|| (((db.ingredients || []).find(i => i.id === id) || {}).name)
|
|
520
|
+
|| (((db.formulations || []).find(x => x.id === id) || {}).name)
|
|
517
521
|
|| id || '?';
|
|
518
522
|
}
|
|
519
523
|
function _trimDoeDesign(d, db) {
|
|
@@ -528,7 +532,7 @@ function _trimDoeDesign(d, db) {
|
|
|
528
532
|
project: proj ? proj.name : null,
|
|
529
533
|
runCount: d.runCount || 0,
|
|
530
534
|
factorCount: (d.factors || []).length,
|
|
531
|
-
factors: (d.factors || []).map(f => ({ ingredient: f.name || nameOf(f
|
|
535
|
+
factors: (d.factors || []).map(f => ({ ingredient: f.name || nameOf(_doeFactorId(f)), low: f.low, high: f.high, unit: f.unit || '%' })),
|
|
532
536
|
constraints: (d.constraints || []).map(c => _doeConstraintText(c, nameOf)),
|
|
533
537
|
mixtureMode: !!d.mixtureMode,
|
|
534
538
|
// The model the design was built for. Lives in settings, so without this a
|
|
@@ -663,10 +667,10 @@ const get_doe_design = {
|
|
|
663
667
|
}
|
|
664
668
|
if (args.include_runs !== false) {
|
|
665
669
|
const facs = d.factors || [];
|
|
666
|
-
out.factorOrder = facs.map(f => f.name || nameOf(f
|
|
670
|
+
out.factorOrder = facs.map(f => f.name || nameOf(_doeFactorId(f)));
|
|
667
671
|
out.runs = (d.runs || []).map((r, i) => {
|
|
668
672
|
const row = { run: i + 1 };
|
|
669
|
-
facs.forEach(f => { row[f.name || nameOf(f
|
|
673
|
+
facs.forEach(f => { row[f.name || nameOf(_doeFactorId(f))] = r[_doeFactorId(f)]; });
|
|
670
674
|
const lf = formsById.get((d.formulationIds || [])[i]);
|
|
671
675
|
row.formulation = lf ? (lf.name || lf.uid) : null;
|
|
672
676
|
return row;
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// DESIGN-SPACE TOOL — the COMPOSITION-space counterpart to
|
|
3
|
+
// get_coverage_matrix (which reports TEST coverage).
|
|
4
|
+
//
|
|
5
|
+
// get_design_space — per project: which ingredients vary and over
|
|
6
|
+
// what observed ranges, a summary of the occupied
|
|
7
|
+
// region ("where you've been"), the biggest
|
|
8
|
+
// untested INTERIOR gap expressed as ingredient
|
|
9
|
+
// ranges (ready to seed a DOE), and — optionally —
|
|
10
|
+
// a standardised 2-component PCA of ALL varying
|
|
11
|
+
// ingredients (loadings + variance-explained + a
|
|
12
|
+
// full-dimensional gap).
|
|
13
|
+
//
|
|
14
|
+
// This is a faithful server-side port of the in-app Design Space Viewer
|
|
15
|
+
// (js/views/assembler-designspace.js, shipped v1.15.128–145). The numeric
|
|
16
|
+
// algorithms are copied verbatim so the MCP answer and the on-screen map
|
|
17
|
+
// always agree:
|
|
18
|
+
// - variation ranking ← _asmDoeGenFactorCandidates (doegen)
|
|
19
|
+
// - observed per-ingredient range ← _asmDoeGenObservedRange (doegen)
|
|
20
|
+
// - top-3/top-2 interior gap ← _asmDsComputeGap
|
|
21
|
+
// - full-dimensional PCA gap ← _asmDsComputeGapPCA
|
|
22
|
+
// - standardised 2-component PCA ← _asmDsPCA
|
|
23
|
+
// - convex hull + point-in-poly ← _asmDsHull / _asmDsPointInPoly
|
|
24
|
+
//
|
|
25
|
+
// Read-only: it computes over the same DB export every other tool reads;
|
|
26
|
+
// it never mutates anything.
|
|
27
|
+
// ============================================================
|
|
28
|
+
|
|
29
|
+
import { getStore, resolveById, flattenComposition } from '../data.js';
|
|
30
|
+
|
|
31
|
+
// ---- composition helper -----------------------------------------------------
|
|
32
|
+
// Ingredient wt-% map for one formula, mirroring the app's _asmIngredientMap:
|
|
33
|
+
// - a pure-% flat composition (no sub-formulas, every row wt%/%) is taken
|
|
34
|
+
// VERBATIM (preserves the common "omit the balance-to-100 row" case);
|
|
35
|
+
// - anything else (absolute units, mixed, or nested) is flattened to true
|
|
36
|
+
// wt-% via the shared flatten engine, so a leaf reached through a premix
|
|
37
|
+
// still counts. Returns Map<ingredientId, percent 0–100>.
|
|
38
|
+
function _ingredientPctMap(f) {
|
|
39
|
+
const out = new Map();
|
|
40
|
+
if (!f) return out;
|
|
41
|
+
const comp = (f.composition || []).filter(c => c && (c.ingredientId || c.formulationId));
|
|
42
|
+
const hasSub = comp.some(c => c.formulationId);
|
|
43
|
+
const allPct = !hasSub && comp.every(c => {
|
|
44
|
+
const u = String(c.unit || 'wt%').trim().toLowerCase();
|
|
45
|
+
return u.startsWith('wt') || u === '%';
|
|
46
|
+
});
|
|
47
|
+
if (allPct) {
|
|
48
|
+
for (const c of comp) {
|
|
49
|
+
if (!c.ingredientId) continue;
|
|
50
|
+
const amt = parseFloat(c.amount);
|
|
51
|
+
out.set(c.ingredientId, (out.get(c.ingredientId) || 0) + (isNaN(amt) ? 0 : amt));
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
const flat = flattenComposition(f); // rows carry weightFraction (0–1)
|
|
56
|
+
for (const r of flat.rows) {
|
|
57
|
+
if (!r.ingredientId) continue;
|
|
58
|
+
const pct = (r.weightFraction || 0) * 100;
|
|
59
|
+
out.set(r.ingredientId, (out.get(r.ingredientId) || 0) + pct);
|
|
60
|
+
}
|
|
61
|
+
// Strip float-precision noise (0.105 + 0.18 = 0.28500000000000003).
|
|
62
|
+
for (const [k, v] of out) out.set(k, parseFloat(v.toFixed(6)));
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// _asmFormulaValueForCol(f, 'ing:'+id) equivalent: the ingredient's wt-% in f,
|
|
67
|
+
// or null when absent (callers coerce null → 0 for the geometry).
|
|
68
|
+
function _ingVal(mapsByForm, f, id) {
|
|
69
|
+
const m = mapsByForm.get(f.id);
|
|
70
|
+
const v = m ? m.get(id) : undefined;
|
|
71
|
+
return (v == null || !Number.isFinite(v)) ? null : v;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- variation ranking (port of _asmDoeGenFactorCandidates, ingredients only) ---
|
|
75
|
+
// A factor is only worth mapping if the data shows it already varies. Score the
|
|
76
|
+
// rest by proportional swing (relative spread) weighted by how broadly they're
|
|
77
|
+
// used, so a pigment ranging 8–14% across many formulas outranks a preservative
|
|
78
|
+
// pinned at 0.2%. Ingredient-only (the design-space view calls with bases off).
|
|
79
|
+
function _factorCandidates(forms, mapsByForm, db) {
|
|
80
|
+
const byId = new Map();
|
|
81
|
+
forms.forEach(f => {
|
|
82
|
+
const map = mapsByForm.get(f.id);
|
|
83
|
+
if (!map) return;
|
|
84
|
+
map.forEach((v, id) => {
|
|
85
|
+
if (v == null || !Number.isFinite(v)) return;
|
|
86
|
+
if (!byId.has(id)) byId.set(id, []);
|
|
87
|
+
byId.get(id).push(v);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
const ingName = new Map((db.ingredients || []).map(i => [i.id, i.name]));
|
|
91
|
+
const cand = [];
|
|
92
|
+
const scoreOf = (amounts) => {
|
|
93
|
+
const min = Math.min(...amounts), max = Math.max(...amounts);
|
|
94
|
+
const spread = max - min;
|
|
95
|
+
if (spread <= 0.01) return null; // effectively constant — not a factor
|
|
96
|
+
const mean = amounts.reduce((a, b) => a + b, 0) / amounts.length;
|
|
97
|
+
const rel = mean > 0 ? spread / mean : 0;
|
|
98
|
+
return { obs: { min, max, mean, n: amounts.length }, spread, rel, score: rel * Math.log2(1 + amounts.length) };
|
|
99
|
+
};
|
|
100
|
+
byId.forEach((amounts, id) => {
|
|
101
|
+
if (amounts.length < 2) return; // need ≥2 uses to see a range
|
|
102
|
+
const name = ingName.get(id);
|
|
103
|
+
if (!name) return; // ingredient since deleted
|
|
104
|
+
const s = scoreOf(amounts);
|
|
105
|
+
if (s) cand.push({ id, name, ...s });
|
|
106
|
+
});
|
|
107
|
+
cand.sort((a, b) => b.score - a.score);
|
|
108
|
+
return cand;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Observed [min,max,mean,n] for one ingredient across the WHOLE library (global,
|
|
112
|
+
// exactly like _asmDoeGenObservedRange — the PCA gap clamps against it).
|
|
113
|
+
function _observedRange(ingredientId, db, mapsByForm) {
|
|
114
|
+
if (!ingredientId) return null;
|
|
115
|
+
const amounts = [];
|
|
116
|
+
(db.formulations || []).forEach(f => {
|
|
117
|
+
if (!f || f._trashed) return;
|
|
118
|
+
const m = mapsByForm.get(f.id) || _ingredientPctMap(f);
|
|
119
|
+
if (!mapsByForm.has(f.id)) mapsByForm.set(f.id, m);
|
|
120
|
+
const v = m.get(ingredientId);
|
|
121
|
+
if (v != null && Number.isFinite(v)) amounts.push(v);
|
|
122
|
+
});
|
|
123
|
+
if (!amounts.length) return null;
|
|
124
|
+
return {
|
|
125
|
+
min: Math.min(...amounts),
|
|
126
|
+
max: Math.max(...amounts),
|
|
127
|
+
mean: amounts.reduce((a, b) => a + b, 0) / amounts.length,
|
|
128
|
+
n: amounts.length,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- geometry (verbatim ports) ---------------------------------------------
|
|
133
|
+
// Andrew's monotone-chain convex hull. Input/return: [{x,y}].
|
|
134
|
+
function _hull(pts) {
|
|
135
|
+
const P = pts.slice().sort((a, b) => a.x - b.x || a.y - b.y);
|
|
136
|
+
if (P.length < 3) return P;
|
|
137
|
+
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
138
|
+
const lower = [];
|
|
139
|
+
for (const p of P) {
|
|
140
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
|
141
|
+
lower.push(p);
|
|
142
|
+
}
|
|
143
|
+
const upper = [];
|
|
144
|
+
for (let i = P.length - 1; i >= 0; i--) {
|
|
145
|
+
const p = P[i];
|
|
146
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
|
|
147
|
+
upper.push(p);
|
|
148
|
+
}
|
|
149
|
+
lower.pop(); upper.pop();
|
|
150
|
+
return lower.concat(upper);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Ray-casting point-in-polygon.
|
|
154
|
+
function _pointInPoly(x, y, poly) {
|
|
155
|
+
if (!poly || poly.length < 3) return false;
|
|
156
|
+
let inside = false;
|
|
157
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
158
|
+
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
|
|
159
|
+
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-12) + xi)) inside = !inside;
|
|
160
|
+
}
|
|
161
|
+
return inside;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- top-K interior gap (port of _asmDsComputeGap) --------------------------
|
|
165
|
+
// The emptiest reachable spot in ABSOLUTE per-ingredient space + the DOE ranges
|
|
166
|
+
// that bracket it. Working per-axis on each ingredient's own observed range (NOT
|
|
167
|
+
// a normalised simplex) is essential: components can differ 10× in scale, and a
|
|
168
|
+
// normalised gap would seed physically silly levels. `comps` = top-2 or top-3
|
|
169
|
+
// candidates. Returns { mode, abs, factors, interior } or null.
|
|
170
|
+
function _computeGap(mode, comps, forms, mapsByForm) {
|
|
171
|
+
const K = comps.length;
|
|
172
|
+
const pts = [];
|
|
173
|
+
forms.forEach(f => {
|
|
174
|
+
const comp = comps.map(c => { const v = _ingVal(mapsByForm, f, c.id); return v == null ? 0 : v; });
|
|
175
|
+
if (comp.every(v => v === 0)) return;
|
|
176
|
+
pts.push(comp);
|
|
177
|
+
});
|
|
178
|
+
if (pts.length < 4) return null; // too few points to trust a "gap"
|
|
179
|
+
const mn = comps.map((_, k) => Math.min(...pts.map(p => p[k])));
|
|
180
|
+
const mx = comps.map((_, k) => Math.max(...pts.map(p => p[k])));
|
|
181
|
+
const span = comps.map((_, k) => Math.max(1e-6, mx[k] - mn[k]));
|
|
182
|
+
const norm = pts.map(p => p.map((v, k) => (v - mn[k]) / span[k])); // unit-cube coords
|
|
183
|
+
const M = 0.05;
|
|
184
|
+
const steps = 12, lo = -M, hiU = 1 + M;
|
|
185
|
+
const g = (i) => lo + (hiU - lo) * i / steps;
|
|
186
|
+
const grid = [];
|
|
187
|
+
if (K === 3) { for (let i = 0; i <= steps; i++) for (let j = 0; j <= steps; j++) for (let l = 0; l <= steps; l++) grid.push([g(i), g(j), g(l)]); }
|
|
188
|
+
else { for (let i = 0; i <= steps; i++) for (let j = 0; j <= steps; j++) grid.push([g(i), g(j)]); }
|
|
189
|
+
const dist = (u) => { let dmin = Infinity; for (const n of norm) { let d = 0; for (let k = 0; k < K; k++) { const dd = n[k] - u[k]; d += dd * dd; } if (d < dmin) dmin = d; } return dmin; };
|
|
190
|
+
// "Bracketed" = real formulas sit on BOTH sides on every axis — a genuine
|
|
191
|
+
// INTERIOR void, not a frontier extrapolation.
|
|
192
|
+
const bracketed = (u) => {
|
|
193
|
+
for (let k = 0; k < K; k++) {
|
|
194
|
+
let below = false, above = false;
|
|
195
|
+
for (const n of norm) { if (n[k] < u[k] - 1e-9) below = true; else if (n[k] > u[k] + 1e-9) above = true; if (below && above) break; }
|
|
196
|
+
if (!(below && above)) return false;
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
};
|
|
200
|
+
// For the 2-D case, ALSO require the gap inside the (shrunk) hull, so it can't
|
|
201
|
+
// sit outside a slanted/correlated cloud.
|
|
202
|
+
let insideHull = () => true;
|
|
203
|
+
if (K === 2) {
|
|
204
|
+
const hull = _hull(norm.map(p => ({ x: p[0], y: p[1] })));
|
|
205
|
+
if (hull.length >= 3) {
|
|
206
|
+
let hcx = 0, hcy = 0; hull.forEach(p => { hcx += p.x; hcy += p.y; }); hcx /= hull.length; hcy /= hull.length;
|
|
207
|
+
const sh = hull.map(p => ({ x: hcx + (p.x - hcx) * 0.85, y: hcy + (p.y - hcy) * 0.85 }));
|
|
208
|
+
insideHull = (u) => _pointInPoly(u[0], u[1], sh);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
let best = null, bestD = -1, interior = false;
|
|
212
|
+
for (const u of grid) { if (!bracketed(u) || !insideHull(u)) continue; const d = dist(u); if (d > bestD) { bestD = d; best = u; interior = true; } }
|
|
213
|
+
if (!best) { for (const u of grid) { const d = dist(u); if (d > bestD) { bestD = d; best = u; } } }
|
|
214
|
+
if (!best) return null;
|
|
215
|
+
const abs = comps.map((_, k) => {
|
|
216
|
+
const t = mn[k] + best[k] * span[k];
|
|
217
|
+
return Math.min(mx[k] + M * span[k], Math.max(Math.max(0, mn[k] - M * span[k]), t));
|
|
218
|
+
});
|
|
219
|
+
const factors = comps.map((c, k) => {
|
|
220
|
+
const hw = Math.max(0.25, span[k] * 0.3); // a real design width, not a point
|
|
221
|
+
return { id: c.id, low: Math.max(0, +(abs[k] - hw).toFixed(3)), high: +(abs[k] + hw).toFixed(3), unit: '%' };
|
|
222
|
+
});
|
|
223
|
+
return { mode, abs, factors, interior };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- standardised 2-component PCA (port of _asmDsPCA) -----------------------
|
|
227
|
+
// Each ingredient z-scored first so a 14% filler and a 0.9% dispersant get equal
|
|
228
|
+
// say. Power iteration + deflation (no libraries), fixed seed → deterministic.
|
|
229
|
+
function _pca(forms, ids, mapsByForm) {
|
|
230
|
+
const n = ids.length; if (n < 2) return null;
|
|
231
|
+
const rows = [];
|
|
232
|
+
forms.forEach(f => { const v = ids.map(id => { const x = _ingVal(mapsByForm, f, id); return x == null ? 0 : x; }); rows.push({ f, v }); });
|
|
233
|
+
const m = rows.length; if (m < 3) return null;
|
|
234
|
+
const mean = ids.map((_, j) => rows.reduce((s, r) => s + r.v[j], 0) / m);
|
|
235
|
+
const std = ids.map((_, j) => { const mu = mean[j]; const varj = rows.reduce((s, r) => s + (r.v[j] - mu) ** 2, 0) / Math.max(1, m - 1); return Math.sqrt(varj) || 1; });
|
|
236
|
+
const Z = rows.map(r => r.v.map((x, j) => (x - mean[j]) / std[j]));
|
|
237
|
+
const C = Array.from({ length: n }, () => new Array(n).fill(0));
|
|
238
|
+
for (let a = 0; a < n; a++) for (let b = a; b < n; b++) { let s = 0; for (let i = 0; i < m; i++) s += Z[i][a] * Z[i][b]; s /= Math.max(1, m - 1); C[a][b] = s; C[b][a] = s; }
|
|
239
|
+
const matVec = (Mx, x) => Mx.map(row => row.reduce((s, e, k) => s + e * x[k], 0));
|
|
240
|
+
const normalize = (x) => { const l = Math.sqrt(x.reduce((s, e) => s + e * e, 0)) || 1; return x.map(e => e / l); };
|
|
241
|
+
const power = (Mx) => { let v = normalize(ids.map((_, i) => Math.sin(i + 1.3))); let lam = 0; for (let it = 0; it < 90; it++) { const wv = matVec(Mx, v); const l = Math.sqrt(wv.reduce((s, e) => s + e * e, 0)) || 1; const vn = wv.map(e => e / l); let diff = 0; for (let k = 0; k < n; k++) diff += Math.abs(vn[k] - v[k]); v = vn; lam = l; if (diff < 1e-9) break; } return { vec: v, val: lam }; };
|
|
242
|
+
const K = Math.min(n, 6);
|
|
243
|
+
const comps = []; let Mx = C;
|
|
244
|
+
for (let c = 0; c < K; c++) { const pc = power(Mx); comps.push(pc); Mx = Mx.map((row, a) => row.map((e, b) => e - pc.val * pc.vec[a] * pc.vec[b])); }
|
|
245
|
+
const pc1 = comps[0], pc2 = comps[1] || comps[0];
|
|
246
|
+
const trace = C.reduce((s, row, a) => s + row[a], 0) || 1;
|
|
247
|
+
const varAll = comps.map(pc => Math.max(0, pc.val) / trace);
|
|
248
|
+
const project = (vabs) => { const z = vabs.map((x, j) => (x - mean[j]) / std[j]); return [z.reduce((s, e, k) => s + e * pc1.vec[k], 0), z.reduce((s, e, k) => s + e * pc2.vec[k], 0)]; };
|
|
249
|
+
const invProject = (x, y) => ids.map((_, k) => mean[k] + (x * pc1.vec[k] + y * pc2.vec[k]) * std[k]);
|
|
250
|
+
const scores = rows.map(r => project(r.v));
|
|
251
|
+
return { ids, rows, scores, project, invProject, load1: pc1.vec, load2: pc2.vec, varAll, var1: varAll[0] || 0, var2: varAll[1] || 0 };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The ingredients each PC is "made of", biggest |weight| first, with sign.
|
|
255
|
+
function _topLoadings(pca, which, count, db) {
|
|
256
|
+
const vec = which === 1 ? pca.load1 : pca.load2;
|
|
257
|
+
const nm = new Map((db.ingredients || []).map(i => [i.id, i.name]));
|
|
258
|
+
return pca.ids.map((id, k) => ({ ingredient: nm.get(id) || id, id, weight: +vec[k].toFixed(4) }))
|
|
259
|
+
.sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight)).slice(0, count || 5);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- full-dimensional PCA gap (port of _asmDsComputeGapPCA) -----------------
|
|
263
|
+
// The emptiest INTERIOR spot in the standardised PCA plane, mapped back to real
|
|
264
|
+
// ingredient levels and clamped to each ingredient's observed range so the
|
|
265
|
+
// seeded DOE stays realistic. Returns { score, factors, interior } or null.
|
|
266
|
+
function _computeGapPCA(pca, db, mapsByForm) {
|
|
267
|
+
const scores = pca.scores; if (scores.length < 4) return null;
|
|
268
|
+
const xs = scores.map(s => s[0]), ys = scores.map(s => s[1]);
|
|
269
|
+
const xMin = Math.min(...xs), xMax = Math.max(...xs), yMin = Math.min(...ys), yMax = Math.max(...ys);
|
|
270
|
+
const sx = (xMax - xMin) || 1, sy = (yMax - yMin) || 1, G = 22;
|
|
271
|
+
const dist = (x, y) => { let mn = Infinity; for (const s of scores) { const dx = (s[0] - x) / sx, dy = (s[1] - y) / sy; const dd = dx * dx + dy * dy; if (dd < mn) mn = dd; } return mn; };
|
|
272
|
+
const hull = _hull(scores.map(s => ({ x: s[0], y: s[1] })));
|
|
273
|
+
let hcx = 0, hcy = 0; hull.forEach(p => { hcx += p.x; hcy += p.y; }); hcx /= (hull.length || 1); hcy /= (hull.length || 1);
|
|
274
|
+
const shrunk = hull.map(p => ({ x: hcx + (p.x - hcx) * 0.82, y: hcy + (p.y - hcy) * 0.82 }));
|
|
275
|
+
const scan = (poly) => { let b = null, bd = -1; for (let i = 0; i <= G; i++) for (let j = 0; j <= G; j++) { const x = xMin + sx * i / G, y = yMin + sy * j / G; if (poly && !_pointInPoly(x, y, poly)) continue; const dd = dist(x, y); if (dd > bd) { bd = dd; b = [x, y]; } } return b; };
|
|
276
|
+
let best = scan(shrunk), interior = !!best;
|
|
277
|
+
if (!best) { best = scan(hull); interior = false; }
|
|
278
|
+
if (!best) { best = scan(null); interior = false; }
|
|
279
|
+
if (!best) return null;
|
|
280
|
+
const abs = pca.invProject(best[0], best[1]);
|
|
281
|
+
const factors = pca.ids.map((id, k) => { const obs = _observedRange(id, db, mapsByForm) || { min: 0, max: 100 }; const hw = Math.max(0.25, (obs.max - obs.min) * 0.3); const t = Math.min(obs.max + hw, Math.max(0, abs[k])); return { id, low: Math.max(0, +(t - hw).toFixed(3)), high: +(t + hw).toFixed(3), unit: '%' }; });
|
|
282
|
+
return { score: best, factors, interior };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- eligibility: projects worth mapping (≥3 formulas) ----------------------
|
|
286
|
+
function _eligibleProjects(db) {
|
|
287
|
+
const counts = new Map();
|
|
288
|
+
(db.formulations || []).forEach(f => { if (f && !f._trashed && f.projectId) counts.set(f.projectId, (counts.get(f.projectId) || 0) + 1); });
|
|
289
|
+
const out = [];
|
|
290
|
+
(db.projects || []).forEach(p => { const n = counts.get(p.id) || 0; if (n >= 3) out.push({ id: p.id, name: p.name || p.id, n }); });
|
|
291
|
+
out.sort((a, b) => b.n - a.n);
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const get_design_space = {
|
|
296
|
+
definition: {
|
|
297
|
+
name: 'get_design_space',
|
|
298
|
+
description: 'Map a project\'s COMPOSITION design space (the descriptive mirror of the DOE designer, and the composition-space counterpart to get_coverage_matrix\'s TEST coverage). For one project it returns: the ingredients that actually VARY across its formulas + their observed wt-% ranges (ranked by proportional swing); an occupied-region summary ("where you\'ve been"); the biggest untested INTERIOR gap — a combination you could have made but skipped, kept inside the occupied cloud — expressed as ready-to-seed DOE ingredient ranges (low/high %); and, when the project has ≥3 varying ingredients, a standardised 2-component PCA of ALL of them: per-axis ingredient loadings, variance-explained, a scree list, and a full-dimensional gap. Numbers match FormLab\'s in-app Design Space Viewer exactly. Use it to answer "where haven\'t I explored?" / "what should I formulate next?" and to hand a chemist an experiment target. Read-only.',
|
|
299
|
+
inputSchema: {
|
|
300
|
+
type: 'object',
|
|
301
|
+
properties: {
|
|
302
|
+
project: { type: 'string', description: 'Project id, UID, or name (case-insensitive; substring allowed). Omit to auto-pick the most-populated eligible project (≥3 formulas).' },
|
|
303
|
+
include_pca: { type: 'boolean', description: 'Include the "map everything" PCA of all varying ingredients (loadings + variance + full-dimensional gap). Default true. Needs ≥3 varying ingredients and ≥3 formulas.' },
|
|
304
|
+
loadings_per_component: { type: 'number', description: 'How many top ingredient loadings to list per principal component. Default 5, max 20.' },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
handler: async (args) => {
|
|
309
|
+
const { db } = getStore();
|
|
310
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
311
|
+
|
|
312
|
+
// Resolve the project (explicit pick, else the most-populated eligible one).
|
|
313
|
+
let proj = null;
|
|
314
|
+
if (args.project) {
|
|
315
|
+
proj = resolveById('projects', args.project)
|
|
316
|
+
|| (db.projects || []).find(p => norm(p.name) === norm(args.project))
|
|
317
|
+
|| (db.projects || []).find(p => norm(p.name).includes(norm(args.project)));
|
|
318
|
+
if (!proj) return { error: `No project matched "${args.project}".` };
|
|
319
|
+
} else {
|
|
320
|
+
const elig = _eligibleProjects(db);
|
|
321
|
+
if (!elig.length) return { error: 'No project has ≥3 formulas to map. Assign at least 3 formulas to a project first.' };
|
|
322
|
+
proj = (db.projects || []).find(p => p.id === elig[0].id);
|
|
323
|
+
}
|
|
324
|
+
const pid = proj.id;
|
|
325
|
+
const forms = (db.formulations || []).filter(f => f && !f._trashed && f.projectId === pid);
|
|
326
|
+
|
|
327
|
+
// Precompute ingredient maps once (reused by ranking, gap, PCA, observed range).
|
|
328
|
+
const mapsByForm = new Map(forms.map(f => [f.id, _ingredientPctMap(f)]));
|
|
329
|
+
|
|
330
|
+
const out = {
|
|
331
|
+
project: { id: pid, uid: proj.uid || null, name: proj.name || pid, formulaCount: forms.length },
|
|
332
|
+
notes: [],
|
|
333
|
+
};
|
|
334
|
+
if (forms.length < 3) {
|
|
335
|
+
out.notes.push('This project has fewer than 3 formulas — a design space is only coherent with at least 3 points. Results below are provisional.');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Varying ingredients + observed ranges (ranked).
|
|
339
|
+
const cand = _factorCandidates(forms, mapsByForm, db);
|
|
340
|
+
out.varyingIngredientCount = cand.length;
|
|
341
|
+
out.varyingIngredients = cand.map(c => ({
|
|
342
|
+
ingredient: c.name,
|
|
343
|
+
id: c.id,
|
|
344
|
+
observedRange: { min: +c.obs.min.toFixed(4), max: +c.obs.max.toFixed(4), mean: +c.obs.mean.toFixed(4), unit: '%', usedInFormulas: c.obs.n },
|
|
345
|
+
relativeSpread: +c.rel.toFixed(4),
|
|
346
|
+
variationScore: +c.score.toFixed(4),
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
if (cand.length < 2) {
|
|
350
|
+
out.notes.push(`Only ${cand.length} ingredient${cand.length === 1 ? '' : 's'} vary across this project — a space needs at least 2 that change from formula to formula. Nothing to map yet.`);
|
|
351
|
+
out.axes = null;
|
|
352
|
+
out.occupiedRegion = null;
|
|
353
|
+
out.gap = null;
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Axes: top-3 ternary (relative proportions) or top-2 scatter (absolute %).
|
|
358
|
+
const mode = cand.length >= 3 ? 'ternary' : 'scatter';
|
|
359
|
+
const comps = cand.slice(0, mode === 'ternary' ? 3 : 2);
|
|
360
|
+
out.axes = {
|
|
361
|
+
mode,
|
|
362
|
+
basis: mode === 'ternary'
|
|
363
|
+
? 'Top 3 most-varying ingredients, as relative proportions (a ternary).'
|
|
364
|
+
: 'Top 2 most-varying ingredients, as absolute wt-%.',
|
|
365
|
+
ingredients: comps.map(c => c.name),
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// Occupied-region summary: the bounding envelope of the plotted axes + how
|
|
369
|
+
// many formulas actually sit in the space. The app draws the convex hull of
|
|
370
|
+
// these points; the per-axis min/max here is that hull's bounding box.
|
|
371
|
+
let inSpace = 0;
|
|
372
|
+
forms.forEach(f => { if (comps.some(c => _ingVal(mapsByForm, f, c.id) != null)) inSpace++; });
|
|
373
|
+
out.occupiedRegion = {
|
|
374
|
+
formulaPoints: inSpace,
|
|
375
|
+
axisRanges: comps.map(c => ({ ingredient: c.name, id: c.id, min: +c.obs.min.toFixed(4), max: +c.obs.max.toFixed(4), unit: '%' })),
|
|
376
|
+
note: 'Bounding envelope of the observed compositions on the mapped axes. In the app this is the shaded convex hull ("where you\'ve been"); the untested gap below was found INSIDE that occupied cloud, not on its frontier.',
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// The top untested interior gap, as ready-to-seed DOE ranges.
|
|
380
|
+
const gap = _computeGap(mode, comps, forms, mapsByForm);
|
|
381
|
+
if (!gap) {
|
|
382
|
+
out.gap = null;
|
|
383
|
+
out.notes.push('Too few distinct formula points (need ≥4 with these axes) to locate a trustworthy gap.');
|
|
384
|
+
} else {
|
|
385
|
+
out.gap = {
|
|
386
|
+
interior: gap.interior,
|
|
387
|
+
basis: `${mode === 'ternary' ? 'top-3' : 'top-2'} varying ingredients`,
|
|
388
|
+
target: comps.map((c, k) => ({
|
|
389
|
+
ingredient: c.name,
|
|
390
|
+
id: c.id,
|
|
391
|
+
center: +gap.abs[k].toFixed(3),
|
|
392
|
+
low: gap.factors[k].low,
|
|
393
|
+
high: gap.factors[k].high,
|
|
394
|
+
unit: '%',
|
|
395
|
+
})),
|
|
396
|
+
note: gap.interior
|
|
397
|
+
? 'Interior void: real formulas bracket this point on every axis — a combination you could have made but skipped. The low/high ranges are ready to seed a DOE.'
|
|
398
|
+
: 'Frontier point (fallback): the cluster was too thin to bracket a true interior void, so this is the farthest-from-any-formula point — an extrapolation; sanity-check before running.',
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Optional: PCA over ALL varying ingredients.
|
|
403
|
+
const wantPca = args.include_pca !== false;
|
|
404
|
+
if (wantPca && cand.length >= 3 && forms.length >= 3) {
|
|
405
|
+
const ids = cand.map(c => c.id);
|
|
406
|
+
const pca = _pca(forms, ids, mapsByForm);
|
|
407
|
+
if (pca) {
|
|
408
|
+
const nLoad = Math.min(20, Math.max(1, args.loadings_per_component || 5));
|
|
409
|
+
const varPct = Math.round((pca.var1 + pca.var2) * 100);
|
|
410
|
+
const pcaOut = {
|
|
411
|
+
ingredientCount: ids.length,
|
|
412
|
+
varianceExplained: { pc1: +pca.var1.toFixed(4), pc2: +pca.var2.toFixed(4), cumulative: +((pca.var1 + pca.var2)).toFixed(4) },
|
|
413
|
+
components: [
|
|
414
|
+
{ component: 1, varianceExplained: +pca.var1.toFixed(4), loadings: _topLoadings(pca, 1, nLoad, db) },
|
|
415
|
+
{ component: 2, varianceExplained: +pca.var2.toFixed(4), loadings: _topLoadings(pca, 2, nLoad, db) },
|
|
416
|
+
],
|
|
417
|
+
scree: pca.varAll.map((v, i) => ({ component: i + 1, varianceExplained: +v.toFixed(4) })),
|
|
418
|
+
note: 'Axes are ingredient combinations, not single ingredients — read the loadings to interpret them. + weight means the ingredient increases toward that axis\'s positive end. Each ingredient is standardised (z-scored) first, so a trace dispersant and a bulk filler get equal say.',
|
|
419
|
+
};
|
|
420
|
+
if (varPct < 60) pcaOut.warning = `PC1+PC2 hold only ${varPct}% of the variation — a rough overview; structure may live in components not shown (see scree).`;
|
|
421
|
+
const gapPca = _computeGapPCA(pca, db, mapsByForm);
|
|
422
|
+
if (gapPca) {
|
|
423
|
+
pcaOut.gap = {
|
|
424
|
+
interior: gapPca.interior,
|
|
425
|
+
dimensionality: 'full (all varying ingredients)',
|
|
426
|
+
target: gapPca.factors.map(fac => {
|
|
427
|
+
const c = cand.find(x => x.id === fac.id);
|
|
428
|
+
return { ingredient: c ? c.name : fac.id, id: fac.id, low: fac.low, high: fac.high, unit: '%' };
|
|
429
|
+
}),
|
|
430
|
+
note: gapPca.interior
|
|
431
|
+
? 'Full-dimensional interior void: emptiest spot inside the occupied PCA cloud, mapped back to real ingredient levels and clamped to each ingredient\'s observed range. Ready to seed a DOE across all varying ingredients.'
|
|
432
|
+
: 'Frontier fallback (not a bracketed interior void) — sanity-check before running.',
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
out.pca = pcaOut;
|
|
436
|
+
}
|
|
437
|
+
} else if (wantPca) {
|
|
438
|
+
out.notes.push(`PCA needs ≥3 varying ingredients and ≥3 formulas (have ${cand.length} varying, ${forms.length} formulas) — skipped.`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!out.notes.length) delete out.notes;
|
|
442
|
+
return out;
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
export const tools = {
|
|
447
|
+
get_design_space,
|
|
448
|
+
};
|