Resources Numerical accuracy Calculation policy

Lab results to stream composition

Turns lab results in mass, mole, ppm or mg/L terms into a proposed stream composition in mole fractions, and writes calculated compositions back in the lab's terms.

How the precision is kept

Each result is scaled by a power of ten (or by the density for mg/L), divided by the molar mass and normalized by the sum, in 64-bit. The sum of the mass fractions is checked against 1 with a tolerance of 0.005 either way.

Calculations under this policy

2 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

process/env/lims.js CadCraft 0.8.2 64-bit 2 calculations Reviewed

Tests tools/process-test/test-env.mjs

Proposed composition from lab results

64-bit

The mole fractions a sample's results give for a stream, for a person to accept or reject.

w = % ÷ 100; w = ppm × 1e-6; w = (mg/L × 1e-3) ÷ ρ; nᵢ = wᵢ ÷ MWᵢ; zᵢ = nᵢ ÷ Σ n

64-bit throughout; the mole amounts are summed in component order and each is divided by the total. The balance component gets max(0, 1 − Σ w), so a sum slightly above 1 gives it zero. The percentage in the warning text is rounded to one decimal; the composition is not rounded.

  • process/env/lims.js
  • lines 83 to 114
  • CadCraft 0.8.2
/** map { analytes: { 'Methanol': { component: 'methanol', basis: 'mass_frac' | 'mole_frac' | 'mass_pct' | 'mole_pct' | 'ppm_mass' | 'mg_per_l' (needs density) } }, balance?: 'water', density?: kg/m3 }.
 *  → { stream: { z (mole fractions in th order), T, P }, rows, problems, provenance, review }  — a PROPOSAL. */
export function proposeStream(sample, results, map, th, { maxAgeDays = null, now = Date.now(), system = '' } = {}) {
  const problems = [], rows = [], about = [{ kind: 'sample', id: sample.id }], mass = new Array(th.n).fill(null), mole = new Array(th.n).fill(null);
  for (const r of results) {
    const m = map.analytes?.[r.analyte];
    if (!m) { problems.push(problem('unmapped', 'note', `Analyte "${r.analyte}" has no component in the map; it was left out.`, about)); continue; }
    const i = th.ids.indexOf(m.component); if (i < 0) { problems.push(problem('nocomponent', 'warning', `Component "${m.component}" is not in this flowsheet.`, about)); continue; }
    if (r.value == null) { problems.push(problem('novalue', 'warning', `${r.analyte} has no numeric value.`, about)); continue; }
    if (r.flag && /^(R|F|INV|REJ)/i.test(r.flag)) { problems.push(problem('flagged', 'warning', `${r.analyte} is flagged "${r.flag}" in the source and was left out.`, about)); continue; }
    const mw = th.c[i].MW; let v = r.value;
    switch (m.basis) {
      case 'mass_frac': mass[i] = v; break; case 'mass_pct': mass[i] = v / 100; break; case 'ppm_mass': mass[i] = v * 1e-6; break;
      case 'mole_frac': mole[i] = v; break; case 'mole_pct': mole[i] = v / 100; break;
      case 'mg_per_l': if (!(map.density > 0)) { problems.push(problem('density', 'warning', `${r.analyte} is in mg/L; the map needs a density (kg/m3) to turn that into a fraction.`, about)); continue; } mass[i] = v * 1e-3 / map.density; break;
      default: problems.push(problem('basis', 'warning', `${r.analyte}: unknown basis "${m.basis}".`, about)); continue;
    }
    rows.push({ analyte: r.analyte, component: m.component, value: r.value, unit: r.unit, basis: m.basis, resultId: r.id });
  }
  // everything to mole amounts
  let n = new Array(th.n).fill(0), used = 0;
  for (let i = 0; i < th.n; i++) { if (mass[i] != null) { n[i] = mass[i] / th.c[i].MW; used += mass[i]; } else if (mole[i] != null) n[i] = mole[i]; }
  const anyMass = mass.some((v) => v != null), anyMole = mole.some((v) => v != null);
  if (anyMass && anyMole) problems.push(problem('mixed', 'warning', 'Some analytes are on a mass basis and some on a mole basis; the proposal mixes them as moles per kilogram and per mole, which is not meaningful. Use one basis.', about));
  if (anyMass && used > 1.005) problems.push(problem('over', 'error', `The mass fractions add up to ${(used * 100).toFixed(1)} %.`, about));
  if (map.balance && anyMass) { const i = th.ids.indexOf(map.balance); if (i >= 0 && mass[i] == null) n[i] = Math.max(0, 1 - used) / th.c[i].MW; else problems.push(problem('balance', 'warning', 'The balance component is missing or already measured.', about)); }
  else if (anyMass && used < 0.995) problems.push(problem('under', 'warning', `The mass fractions add up to only ${(used * 100).toFixed(1)} % and no balance component is named; they were normalised.`, about));
  const tot = n.reduce((a, b) => a + b, 0); let z = null;
  if (tot > 0) z = n.map((v) => v / tot); else problems.push(problem('empty', 'error', 'No usable result: the proposal has no composition.', about));
  if (maxAgeDays != null && sample.collected) { const t = Date.parse(sample.collected); if (Number.isFinite(t) && (now - t) / 864e5 > maxAgeDays) problems.push(problem('stale', 'warning', `The sample was collected ${Math.round((now - t) / 864e5)} days ago (limit ${maxAgeDays}).`, about)); }
  return { kind: 'cadcraft-lims-proposal', stream: { z, T: sample.temperature, P: sample.pressure }, rows, problems, provenance: { system: str(system), sample: sample.id, name: sample.name, results: rows.map((r) => r.resultId), collected: sample.collected }, review: { state: 'proposed' }, disclaimer: DISCLAIMER };
}

Calculated composition for the lab system

64-bit

A stream's composition as mass or mole fractions, percent or ppm, to send back as calculated values.

wᵢ = zᵢ × MWᵢ ÷ Σ(z × MW); mass % = w × 100; ppm = w × 1e6

One division per component in 64-bit; a zero total is replaced by 1. For an analyte mapped on the mg_per_l basis the value returned is the mass fraction with the unit 'fraction', not a concentration in mg/L.

  • process/env/lims.js
  • lines 118 to 123
  • CadCraft 0.8.2
/** Calculated values to send back (only through a connector that writes): mass fractions of mapped analytes, labelled calculated. */
export function resultsFromStream(stream, th, map) {
  const out = []; const w = th.ids.map((_, i) => stream.z[i] * th.c[i].MW), tot = w.reduce((a, b) => a + b, 0) || 1;
  for (const [analyte, m] of Object.entries(map.analytes || {})) { const i = th.ids.indexOf(m.component); if (i < 0) continue; const mf = w[i] / tot; const v = m.basis === 'mass_pct' ? mf * 100 : m.basis === 'ppm_mass' ? mf * 1e6 : m.basis === 'mole_frac' ? stream.z[i] : m.basis === 'mole_pct' ? stream.z[i] * 100 : mf; out.push({ analyte, value: v, unit: m.basis === 'mass_pct' || m.basis === 'mole_pct' ? '%' : m.basis === 'ppm_mass' ? 'ppm' : 'fraction', calculated: true, note: 'Calculated by the flowsheet, not measured.' }); }
  return out;
}

In detail

Method
Mass fraction ÷ molar mass gives a mole amount; a named balance component takes 1 − Σ mass fractions; each mole fraction is its amount ÷ the total.
Number types
64-bit numbers; values that arrive as text are converted with the unary plus.
Tolerances
A sum of mass fractions above 1.005 is an error; below 0.995 with no balance component is a warning and the values are normalized.
Limits
Results on a mass basis and on a mole basis in one sample are combined as they are and flagged as not meaningful. A calculated value for an analyte mapped as mg/L is written back as a mass fraction.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach