formlab-mcp 0.6.13 → 0.6.15

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 CHANGED
@@ -45,6 +45,8 @@ Ask Claude (or another MCP client) things like:
45
45
  | `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
46
46
  | `find_failures` | Pareto-style: parameters that fail acceptance most often |
47
47
  | `get_coverage_matrix` | Which formulations × parameters have been measured (TEST coverage) |
48
+ | `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` |
49
+ | `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 |
48
50
  | `get_stability` | Grounded stability / shelf-life analysis for a formulation, sample or batch × parameter (auto-detected): time-series, drift (per-month slope + R²), an I-chart (mean ±3σ + out-of-control points), spec status, and an ICH-Q1E-flavored projected shelf life (point + 95%-CI crossing), split by storage condition |
49
51
  | `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?"* |
50
52
  | `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` |
@@ -146,7 +148,7 @@ Or for local-dev (from the repo):
146
148
  }
147
149
  ```
148
150
 
149
- Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 34 tools become callable in any conversation.
151
+ Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 36 tools become callable in any conversation.
150
152
 
151
153
  ## Wire it up to Claude Code
152
154
 
package/SUBMISSION.md CHANGED
@@ -47,7 +47,7 @@ _"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
- **34 read-only tools** covering formulations, ingredients, batches, samples,
50
+ **36 read-only tools** covering formulations, ingredients, batches, samples,
51
51
  test results, DOE matrices + saved designs, similarity, failure analysis,
52
52
  test-coverage AND composition design-space mapping (varying ingredients,
53
53
  untested gaps, PCA), equipment, test methods, panels, projects and ELN notes.
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 33 tools (see ./tools/) over stdio. The MCP host (Claude
10
+ // Exposes 36 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.
@@ -37,6 +37,7 @@ import * as designspace from './tools/designspace.js';
37
37
  import * as library from './tools/library.js';
38
38
  import * as projects from './tools/projects.js';
39
39
  import * as notebook from './tools/notebook.js';
40
+ import * as batchAnalyze from './tools/batch-analyze.js';
40
41
 
41
42
  // ----- Data source: LIVE cloud workspace OR a static export file -----
42
43
  // Cloud mode kicks in when a dedicated read-only MCP token is present (mint it
@@ -116,6 +117,7 @@ const TOOLS = [
116
117
  ...Object.values(library.tools),
117
118
  ...Object.values(projects.tools),
118
119
  ...Object.values(notebook.tools),
120
+ ...Object.values(batchAnalyze.tools),
119
121
  ];
120
122
 
121
123
  // Version reported in the MCP handshake. Read from package.json rather than
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
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.6.13",
5
+ "version": "0.6.15",
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.13",
16
+ "version": "0.6.15",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -0,0 +1,234 @@
1
+ // ============================================================
2
+ // Batch analysis tools (read-only) — parity with the in-app Batch Analyze panel
3
+ // (js/views/batches-compare.js). Spec: docs/MCP_BATCH_ANALYZE_SPEC.md.
4
+ // - compare_batches: reproducibility of N runs (yield / produced / cost,
5
+ // per-ingredient drift vs the proposal, outlier run).
6
+ // - get_batch_pivot: production analytics (group_by × metric).
7
+ // Computes from the SAME primitives as the app so numbers match: flattened
8
+ // wt-% (data.js flattenComposition), _batchActualMassKg (tools/lab.js),
9
+ // _costPerKg (tools/ingredients.js).
10
+ // ============================================================
11
+
12
+ import { getStore, resolveById, flattenComposition } from '../data.js';
13
+ import { _batchActualMassKg, _massToKg } from './lab.js';
14
+ import { _costPerKg } from './ingredients.js';
15
+
16
+ // ---- shared compute (mirrors _bcActualPctMap / _bcProposedPctMap / _bcSpreadLevel) ----
17
+ function _pctMap(compositionHost) {
18
+ const out = new Map();
19
+ if (!compositionHost) return out;
20
+ const flat = flattenComposition(compositionHost);
21
+ for (const r of (flat.rows || [])) {
22
+ if (!r.ingredientId) continue;
23
+ out.set(r.ingredientId, (out.get(r.ingredientId) || 0) + r.weightFraction * 100);
24
+ }
25
+ for (const [k, v] of out) out.set(k, +v.toFixed(4));
26
+ return out;
27
+ }
28
+ const _actualPctMap = b => (Array.isArray(b.actualComposition) && b.actualComposition.length)
29
+ ? _pctMap({ composition: b.actualComposition }) : new Map();
30
+ const _proposedPctMap = f => f ? _pctMap(f) : new Map();
31
+
32
+ // Actual amount (as entered) per ingredient — top-level rows only, {amount, unit}.
33
+ function _amountMap(b) {
34
+ const out = new Map();
35
+ for (const c of (b.actualComposition || [])) {
36
+ if (!c || !c.ingredientId) continue;
37
+ const amt = parseFloat(c.amount);
38
+ if (!isFinite(amt)) continue;
39
+ const prev = out.get(c.ingredientId);
40
+ if (!prev) out.set(c.ingredientId, { amount: amt, unit: c.unit || '' });
41
+ else out.set(c.ingredientId, { amount: prev.amount + amt, unit: prev.unit });
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function _targetKg(b) { const q = parseFloat(b.targetQuantity); return isFinite(q) ? _massToKg(q, b.unit) : null; }
47
+ function _yieldPct(b) { const tk = _targetKg(b), mk = _batchActualMassKg(b); return (tk && tk > 0 && mk != null) ? +(mk / tk * 100).toFixed(1) : null; }
48
+
49
+ // Blend cost per kg = Σ ingredient costPerKg × weightFraction over the flattened
50
+ // composition (actual if logged, else the linked formula). Purity gross-up and
51
+ // currency conversion the app applies are NOT modeled here — this is a
52
+ // currency-per-kg estimate in each ingredient's own cost currency.
53
+ function _batchCostPerKg(b, formula) {
54
+ const db = getStore().db;
55
+ const host = (Array.isArray(b.actualComposition) && b.actualComposition.length) ? { composition: b.actualComposition } : formula;
56
+ if (!host) return null;
57
+ const flat = flattenComposition(host);
58
+ let sum = 0, any = false;
59
+ for (const r of (flat.rows || [])) {
60
+ const ing = (db.ingredients || []).find(i => i.id === r.ingredientId);
61
+ const cpk = ing ? _costPerKg(ing) : null;
62
+ if (cpk != null) { sum += cpk * r.weightFraction; any = true; }
63
+ }
64
+ return any ? +sum.toFixed(4) : null;
65
+ }
66
+
67
+ // Drift severity across a run of values. wt%: absolute + relative thresholds;
68
+ // amount: relative-only (units differ). 0 consistent, 1 minor, 2 large.
69
+ function _spreadLevel(vals, relOnly) {
70
+ if (!vals || vals.length < 2) return { level: 0, spread: 0 };
71
+ const max = Math.max(...vals), min = Math.min(...vals), spread = max - min;
72
+ const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
73
+ const rel = mean > 0 ? spread / mean : (spread > 0 ? 1 : 0);
74
+ let level = 0;
75
+ if (relOnly) { if (rel >= 0.15) level = 2; else if (rel >= 0.05) level = 1; }
76
+ else { if (spread >= 1 || rel >= 0.15) level = 2; else if (spread >= 0.25 || rel >= 0.05) level = 1; }
77
+ return { level, spread: +spread.toFixed(4) };
78
+ }
79
+
80
+ function _batchTestCount(b, db) {
81
+ const sIds = new Set((db.samples || []).filter(s => s.batchId === b.id).map(s => s.id));
82
+ return (db.testResults || []).filter(t => sIds.has(t.sampleId)).length;
83
+ }
84
+
85
+ // ============================================================
86
+ // compare_batches
87
+ // ============================================================
88
+ const compare_batches = {
89
+ definition: {
90
+ name: 'compare_batches',
91
+ description: 'Compare 2+ batches (ideally runs of ONE formula) for reproducibility: each run\'s yield %, actual produced mass, and cost/kg; per-ingredient drift across the runs vs the formula\'s proposed wt-%; and the biggest outlier run. Best for "are these runs consistent / which drifted / which is the outlier". basis="wt_percent" (default, size-normalized) or "amount" (as-entered).',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ batch_uids: { type: 'array', items: { type: 'string' }, description: 'Batch ids or UIDs (e.g. ["BATCH-0017","BATCH-0019"]). 2 or more.' },
96
+ basis: { type: 'string', enum: ['wt_percent', 'amount'], description: 'wt_percent (default) or amount (as entered, top-level rows).' },
97
+ },
98
+ required: ['batch_uids'],
99
+ },
100
+ },
101
+ handler: async (args) => {
102
+ if (!Array.isArray(args.batch_uids) || args.batch_uids.length < 2) return { error: 'Pass at least 2 batch ids/uids.' };
103
+ const db = getStore().db;
104
+ const batches = args.batch_uids.map(id => resolveById('batches', id)).filter(Boolean);
105
+ if (batches.length < 2) return { error: 'Fewer than 2 of the supplied ids resolved to existing batches.' };
106
+ const basis = args.basis === 'amount' ? 'amount' : 'wt_percent';
107
+
108
+ const cols = batches.map(b => {
109
+ const f = (db.formulations || []).find(x => x.id === b.formulationId) || null;
110
+ const hasActual = Array.isArray(b.actualComposition) && b.actualComposition.length > 0;
111
+ const mk = _batchActualMassKg(b);
112
+ return { b, f, hasActual, actMap: _actualPctMap(b), amtMap: _amountMap(b),
113
+ yield_pct: _yieldPct(b), produced_kg: mk != null ? +mk.toFixed(4) : null, cost_per_kg: _batchCostPerKg(b, f) };
114
+ });
115
+ const fIds = new Set(batches.map(b => b.formulationId));
116
+ const shared = fIds.size === 1 ? cols[0].f : null;
117
+ const proposed = (basis === 'wt_percent' && shared) ? _proposedPctMap(shared) : new Map();
118
+ const colsAct = cols.filter(c => c.hasActual);
119
+
120
+ // ingredient universe
121
+ const ingIds = new Set();
122
+ proposed.forEach((_v, k) => ingIds.add(k));
123
+ cols.forEach(c => (basis === 'amount' ? c.amtMap : c.actMap).forEach((_v, k) => ingIds.add(k)));
124
+
125
+ const composition = [...ingIds].map(ingId => {
126
+ const ing = (db.ingredients || []).find(i => i.id === ingId);
127
+ const runs = {};
128
+ const present = [];
129
+ let unit = null, sameUnit = true;
130
+ colsAct.forEach(c => {
131
+ const uid = c.b.uid || c.b.id;
132
+ if (basis === 'amount') {
133
+ const e = c.amtMap.get(ingId);
134
+ if (e) { runs[uid] = +(+e.amount).toFixed(4); present.push(e.amount); if (unit == null) unit = e.unit; else if (unit !== e.unit) sameUnit = false; }
135
+ } else {
136
+ if (c.actMap.has(ingId)) { runs[uid] = c.actMap.get(ingId); present.push(c.actMap.get(ingId)); }
137
+ }
138
+ });
139
+ const sev = (present.length >= 2 && (basis === 'wt_percent' || sameUnit)) ? _spreadLevel(present, basis === 'amount') : { level: 0, spread: 0 };
140
+ const row = { ingredient: ing?.name || ingId, cas: ing?.cas || ing?.casNumber || '', runs, spread: sev.spread, drift_level: sev.level };
141
+ if (basis === 'wt_percent') row.proposed_wt_pct = proposed.has(ingId) ? proposed.get(ingId) : null;
142
+ if (basis === 'amount' && unit) row.unit = unit;
143
+ return row;
144
+ }).sort((a, b) => b.drift_level - a.drift_level || b.spread - a.spread || String(a.ingredient).localeCompare(b.ingredient));
145
+
146
+ // outlier: max summed |Δ from cross-run mean| leading the runner-up by >15% (wt% basis, 3+ runs)
147
+ let outlier_uid = null;
148
+ if (basis === 'wt_percent' && colsAct.length >= 3) {
149
+ const union = new Set(); colsAct.forEach(c => c.actMap.forEach((_v, k) => union.add(k)));
150
+ const mean = new Map();
151
+ union.forEach(id => { const p = colsAct.filter(c => c.actMap.has(id)); if (p.length >= 2) mean.set(id, p.reduce((s, c) => s + c.actMap.get(id), 0) / p.length); });
152
+ const scores = colsAct.map(c => { let s = 0; mean.forEach((mv, id) => { if (c.actMap.has(id)) s += Math.abs(c.actMap.get(id) - mv); }); return { uid: c.b.uid || c.b.id, s }; }).sort((a, b) => b.s - a.s);
153
+ if (scores.length >= 2 && scores[0].s > 0 && scores[0].s > scores[1].s * 1.15) outlier_uid = scores[0].uid;
154
+ }
155
+ const drifting = composition.filter(r => r.drift_level >= 1).length;
156
+
157
+ return {
158
+ runs: cols.map(c => ({
159
+ uid: c.b.uid || c.b.id, formula: (c.f && c.f.name) || c.b.formulationName || null,
160
+ status: c.b.status || null, prepared_date: c.b.preparedDate || null, prepared_by: c.b.preparedBy || null,
161
+ target: c.b.targetQuantity != null ? { qty: c.b.targetQuantity, unit: c.b.unit || null } : null,
162
+ actual_produced_kg: c.produced_kg, yield_pct: c.yield_pct, cost_per_kg: c.cost_per_kg,
163
+ has_actual_composition: c.hasActual, is_outlier: (c.b.uid || c.b.id) === outlier_uid,
164
+ })),
165
+ basis, shared_formula: shared ? shared.name : null,
166
+ composition,
167
+ summary: { runs_with_actuals: colsAct.length, drifting_ingredients: drifting, outlier_uid, consistent: colsAct.length >= 2 && drifting === 0 },
168
+ };
169
+ },
170
+ };
171
+
172
+ // ============================================================
173
+ // get_batch_pivot
174
+ // ============================================================
175
+ const _GROUP_LABEL = { formula: 'Formula', status: 'Status', month: 'Month', prepared_by: 'Prepared by', project: 'Project' };
176
+ const _METRIC_LABEL = { count: 'Batch count', avg_yield_pct: 'Avg yield %', total_produced_kg: 'Total produced (kg)', avg_cost_per_batch: 'Avg cost / batch', total_samples: 'Total samples', total_tests: 'Total tests' };
177
+
178
+ function _groupKey(b, groupBy, db) {
179
+ if (groupBy === 'status') return b.status || '(no status)';
180
+ if (groupBy === 'month') return (b.preparedDate || '').slice(0, 7) || '(no date)';
181
+ if (groupBy === 'prepared_by') return b.preparedBy || '(unknown)';
182
+ if (groupBy === 'project') { const f = (db.formulations || []).find(x => x.id === b.formulationId); const p = f && (db.projects || []).find(pp => pp.id === f.projectId); return p ? (p.name || '(project)') : '(no project)'; }
183
+ return b.formulationName || ((db.formulations || []).find(f => f.id === b.formulationId) || {}).name || '(no formula)';
184
+ }
185
+ function _metricVal(bs, metric, db) {
186
+ if (metric === 'count') return bs.length;
187
+ if (metric === 'avg_yield_pct') { const ys = bs.map(_yieldPct).filter(v => v != null); return ys.length ? +(ys.reduce((a, b) => a + b, 0) / ys.length).toFixed(1) : null; }
188
+ if (metric === 'total_produced_kg') { let kg = 0, any = false; bs.forEach(b => { const m = _batchActualMassKg(b); if (m != null && isFinite(m)) { kg += m; any = true; } }); return any ? +kg.toFixed(3) : null; }
189
+ if (metric === 'avg_cost_per_batch') { const cs = bs.map(b => { const f = (db.formulations || []).find(x => x.id === b.formulationId) || null; const perKg = _batchCostPerKg(b, f); const mk = _batchActualMassKg(b) || _targetKg(b); return (perKg != null && mk != null) ? perKg * mk : null; }).filter(v => v != null); return cs.length ? +(cs.reduce((a, b) => a + b, 0) / cs.length).toFixed(2) : null; }
190
+ if (metric === 'total_samples') return bs.reduce((s, b) => s + (db.samples || []).filter(sm => sm.batchId === b.id).length, 0);
191
+ if (metric === 'total_tests') return bs.reduce((s, b) => s + _batchTestCount(b, db), 0);
192
+ return 0;
193
+ }
194
+
195
+ const get_batch_pivot = {
196
+ definition: {
197
+ name: 'get_batch_pivot',
198
+ description: 'Aggregate batches by a dimension (formula / status / month / prepared_by / project) and a batch-native metric (count, avg_yield_pct, total_produced_kg, avg_cost_per_batch, total_samples, total_tests). The "lab-manager" production view. Optionally scope to specific batch_uids and/or a status filter.',
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {
202
+ group_by: { type: 'string', enum: ['formula', 'status', 'month', 'prepared_by', 'project'], description: 'Grouping dimension (default formula).' },
203
+ metric: { type: 'string', enum: ['count', 'avg_yield_pct', 'total_produced_kg', 'avg_cost_per_batch', 'total_samples', 'total_tests'], description: 'Metric per group (default count).' },
204
+ batch_uids: { type: 'array', items: { type: 'string' }, description: 'Optional — scope to these batches; omit for all.' },
205
+ status: { type: 'string', description: 'Optional status filter (e.g. "Completed").' },
206
+ },
207
+ },
208
+ },
209
+ handler: async (args) => {
210
+ const db = getStore().db;
211
+ const groupBy = _GROUP_LABEL[args.group_by] ? args.group_by : 'formula';
212
+ const metric = _METRIC_LABEL[args.metric] ? args.metric : 'count';
213
+ let batches = db.batches || [];
214
+ let scope = 'all';
215
+ if (Array.isArray(args.batch_uids) && args.batch_uids.length) {
216
+ const ids = new Set(args.batch_uids.map(u => { const b = resolveById('batches', u); return b ? b.id : null; }).filter(Boolean));
217
+ batches = batches.filter(b => ids.has(b.id));
218
+ scope = 'selected';
219
+ }
220
+ if (args.status) batches = batches.filter(b => String(b.status || '').toLowerCase() === String(args.status).toLowerCase());
221
+
222
+ const groups = {};
223
+ batches.forEach(b => { const k = _groupKey(b, groupBy, db); (groups[k] = groups[k] || []).push(b); });
224
+ const additive = ['count', 'total_produced_kg', 'total_samples', 'total_tests'].includes(metric);
225
+ const rows = Object.entries(groups).map(([key, bs]) => ({ key, batches: bs.length, value: _metricVal(bs, metric, db) }))
226
+ .sort((a, b) => (b.value == null ? -Infinity : b.value) - (a.value == null ? -Infinity : a.value) || a.key.localeCompare(b.key));
227
+ const total = additive ? +rows.reduce((s, r) => s + (r.value || 0), 0).toFixed(3) : null;
228
+ if (additive && total) rows.forEach(r => { r.share_pct = +((r.value || 0) / total * 100).toFixed(1); });
229
+
230
+ return { group_by: groupBy, group_by_label: _GROUP_LABEL[groupBy], metric, metric_label: _METRIC_LABEL[metric], scope, batch_count: batches.length, additive, total, rows };
231
+ },
232
+ };
233
+
234
+ export const tools = { compare_batches, get_batch_pivot };
@@ -415,3 +415,6 @@ const list_inventory = {
415
415
  };
416
416
 
417
417
  export const tools = { list_ingredients, get_ingredient, find_by_smarts, list_lots, list_inventory };
418
+
419
+ // Exported for the batch-analyze tools (avg cost metric) to reuse the same cost normalization.
420
+ export { _costPerKg, _densityKgPerL };
package/tools/lab.js CHANGED
@@ -189,6 +189,11 @@ const get_batch = {
189
189
  ..._trimBatch(b, indexes.formulationsById),
190
190
  notes: b.notes || '',
191
191
  equipmentUsed: b.equipmentUsed || '',
192
+ // Multi-select equipment: full list [{name, equipmentId}]; falls back to
193
+ // the legacy single equipmentUsed so older batches still report one item.
194
+ equipmentUsedList: (Array.isArray(b.equipmentUsedList) && b.equipmentUsedList.length)
195
+ ? b.equipmentUsedList.filter(e => e && e.name).map(e => ({ name: e.name, equipmentId: e.equipmentId || null }))
196
+ : (b.equipmentUsed ? [{ name: b.equipmentUsed, equipmentId: b.equipmentUsedId || null }] : []),
192
197
  actualCompositionRowCount: Array.isArray(b.actualComposition) ? b.actualComposition.length : 0,
193
198
  hasActualComposition: Array.isArray(b.actualComposition) && b.actualComposition.length > 0,
194
199
  sourceBlendParents: parents,
@@ -296,3 +301,6 @@ const get_sample = {
296
301
  };
297
302
 
298
303
  export const tools = { list_batches, get_batch, list_samples, get_sample };
304
+
305
+ // Exported for the batch-analyze tools so they reuse the SAME mass logic.
306
+ export { _batchActualMassKg, _massToKg };