Resources Numerical accuracy Calculation policy

Batch schedule and batch inventory

Places a recipe's steps on the plant's vessels for a number of batches and reports start and finish hours, utilization, cycle time and batches per day; keeps a mole inventory of charges, transfers, reactions and discharges.

How the precision is kept

Times are 64-bit hours added in recipe order; nothing is rounded until the schedule is turned into whole days for the Gantt view. A vessel is chosen over another only when it is free more than 1e-12 h earlier. The inventory adds and subtracts moles in 64-bit and reports the largest relative gap in atoms between what was charged and what is held or discharged.

Calculations under this policy

4 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/batch.js CadCraft 0.8.2 64-bit 4 calculations Reviewed

Tests tools/process-test/test-link.mjs, tools/process-test/test-draw4.mjs

Start and finish of each step

64-bit

When each step of each batch starts and finishes, and on which vessel.

release = start + b × interval; t₀ = max(ready, vessel free); t₁ = t₀ + duration; wait = t₀ − ready

Hours are 64-bit and are added step by step, so a long campaign carries the rounding of each addition. A second vessel replaces the first candidate only when its start is more than 1e-12 h earlier, an absolute tolerance.

  • process/batch.js
  • lines 28 to 41
  • CadCraft 0.8.2
for (let b = 0; b < batches; b++) {
  const done = {}, where = {}, release = start + b * interval;
  for (const s of recipe.steps) {
    const ready = Math.max(release, ...(s.after || []).map((a) => done[a]), release);
    const pool = s.sameAs ? eq.filter((e) => e.id === where[s.sameAs]) : s.equip ? eq.filter((e) => e.id === s.equip) : eq.filter((e) => e.cls === s.cls);
    if (!pool.length) throw new Error('step ' + s.id + ' names equipment ' + (s.equip || where[s.sameAs]) + ', which is not in the plant');
    let best = null;
    for (const e of pool) { const t0 = Math.max(ready, free[e.id]); if (!best || t0 < best.t0 - 1e-12) best = { e, t0 }; }
    const t1 = best.t0 + s.dur;
    free[best.e.id] = t1 + (lastUse(s) ? best.e.clean || 0 : 0); busy[best.e.id].push({ batch: b + 1, step: s.id, start: best.t0, end: t1 });
    done[s.id] = t1; where[s.id] = best.e.id; tasks.push({ batch: b + 1, step: s.id, name: s.name || s.id, equip: best.e.id, start: best.t0, finish: t1, wait: best.t0 - ready });
    if (best.e.cap != null && s.size != null && s.size > best.e.cap) problems.push(problem('batch.capacity', 'error', `Batch ${b + 1}, step ${s.name || s.id}: ${s.size} does not fit ${best.e.id} (${best.e.cap}).`, [{ kind: 'unit', id: best.e.id }]));
  }
}

Makespan, utilization, cycle time and batches per day

64-bit

The length of the whole schedule, how busy each vessel is, the steady cycle time and the batches per day it allows.

makespan = max finish − start; utilization = Σ(end − start) ÷ makespan; cycle = (work + clean × uses) ÷ vessels; batches per day = 24 ÷ cycle

Each is one 64-bit expression on the task times. Utilization is 0 when the makespan is not above zero, batches per day is null when the cycle is zero, and the vessel count in the cycle division is at least 1.

  • process/batch.js
  • lines 42 to 51
  • CadCraft 0.8.2
const makespan = Math.max(...tasks.map((t) => t.finish)) - start;
const util = Object.fromEntries(eq.map((e) => [e.id, makespan > 0 ? busy[e.id].reduce((a, i) => a + (i.end - i.start), 0) / makespan : 0]));
// the bottleneck class sets the steady rate: its total work (steps plus cleaning) per batch divided by how many vessels it has
const clsOf = (s) => (s.sameAs ? clsOf(recipe.steps.find((q) => q.id === s.sameAs)) : s.cls || eq.find((e) => e.id === s.equip)?.cls);
const byCls = {}; for (const s of recipe.steps) { const cls = clsOf(s); if (lastUse(s)) (byCls[cls] ||= { work: 0, n: eq.filter((e) => e.cls === cls).length, clean: 0, uses: 0 }).uses++; (byCls[cls] ||= { work: 0, n: eq.filter((e) => e.cls === cls).length, clean: 0, uses: 0 }).work += s.dur; }
for (const cls of Object.keys(byCls)) { const es = eq.filter((e) => e.cls === cls); byCls[cls].clean = es.length ? Math.max(...es.map((e) => e.clean || 0)) : 0; }
let cycle = 0, bottleneck = null;
for (const [cls, v] of Object.entries(byCls)) { const c = (v.work + v.clean * v.uses) / Math.max(1, v.n); if (c > cycle) { cycle = c; bottleneck = cls; } }
const first = tasks.filter((t) => t.batch === 1);
return { tasks, occupancy: busy, makespan, utilisation: util, firstBatchHours: Math.max(...first.map((t) => t.finish)) - start, cycleHours: cycle, bottleneck, batchesPerDay: cycle > 0 ? 24 / cycle : null, problems };

Hours to whole days for the Gantt view

64-bit

The start and finish day of each task for the schedule view, which works in whole days.

start day = day0 + ⌊start ÷ 24 + 1e-9⌋; finish day = day0 + max(⌊(finish − 1e-9) ÷ 24⌋, ⌊start ÷ 24 + 1e-9⌋)

The hour values are floored to whole days, so the time within the day is not kept in these tasks. The two guards are not the same size: 1e-9 of a day is added to the start, 1e-9 of an hour is taken from the finish.

  • process/batch.js
  • lines 54 to 63
  • CadCraft 0.8.2
/** Tasks for schedule-io.js: whole days since 1970-01-01 UTC (Finish inclusive), FS links between a batch's steps. `day0` = day number of hour 0. */
export function toScheduleTasks(sched, { day0 = 0 } = {}) {
  const uid = (t) => `b${t.batch}.${t.step}`;
  const bySt = new Map(sched.tasks.map((t) => [uid(t), t]));
  return sched.tasks.map((t) => {
    const s = day0 + Math.floor(t.start / 24 + 1e-9), f = day0 + Math.max(Math.floor((t.finish - 1e-9) / 24), Math.floor(t.start / 24 + 1e-9));
    const preds = (t.after || []).map((a) => ({ uid: `b${t.batch}.${a}`, type: 'FS', lag: 0 })).filter((p) => bySt.has(p.uid));
    return { uid: uid(t), name: `B${t.batch} ${t.name} (${t.equip})`, start: s, finish: f, stage: 'Batch ' + t.batch, package: t.equip, rows: [], preds };
  });
}

Batch mole inventory and atom balance

64-bit

The moles of each component in each vessel after every operation, what was discharged, and how well the atoms balance.

transfer: q = n × fraction; react: ξ = conversion × n_key ÷ |ν_key|, n ← n + ν × ξ; atom error = max |charged − held| ÷ charged

Moles are added and subtracted in 64-bit in the order of the operations. An amount that falls below -1e-9 mol stops the run; one between -1e-9 and 0 is set to 0. The atom error is relative to the atoms charged, with 1e-30 added to the denominator.

  • process/batch.js
  • lines 102 to 126
  • CadCraft 0.8.2
/**
 * Batch mass balance. components = ids (data.js); ops: { op: 'charge', vessel, comp, mol } | { op: 'transfer', from, to, fraction } |
 * { op: 'react', vessel, nu: { comp: coefficient }, extent (mol) | key + conversion } | { op: 'discharge', vessel }.
 * → { inventory: { vessel: { comp: mol } }, discharged: { comp: mol }, atomError (relative; charged atoms vs held + discharged), steps: [...] }
 */
export function inventory(components, ops) {
  const inv = {}, out = Object.fromEntries(components.map((c) => [c, 0])), charged = {}, steps = [];
  const get = (v) => (inv[v] ||= Object.fromEntries(components.map((c) => [c, 0])));
  const need = (c) => { if (!components.includes(c)) throw new Error(c + ' is not one of the components'); };
  const addAtoms = (acc, c, mol) => { const a = atoms(COMPONENTS[c]?.formula || ''); for (const [k, v] of Object.entries(a)) acc[k] = (acc[k] || 0) + v * mol; };
  for (const o of ops) {
    if (o.op === 'charge') { need(o.comp); get(o.vessel)[o.comp] += o.mol; addAtoms(charged, o.comp, o.mol); }
    else if (o.op === 'transfer') { const a = get(o.from), b = get(o.to), f = o.fraction ?? 1; for (const c of components) { const q = a[c] * f; a[c] -= q; b[c] += q; } }
    else if (o.op === 'react') {
      const v = get(o.vessel); let xi = o.extent;
      if (xi == null) { need(o.key); const nk = o.nu[o.key]; if (!(nk < 0)) throw new Error('the key component must be a reactant'); xi = o.conversion * v[o.key] / Math.abs(nk); }
      for (const [c, nu] of Object.entries(o.nu)) { need(c); v[c] += nu * xi; if (v[c] < -1e-9) throw new Error(`step ${steps.length + 1}: ${c} would go negative in ${o.vessel}`); v[c] = Math.max(v[c], 0); }
    } else if (o.op === 'discharge') { const v = get(o.vessel); for (const c of components) { out[c] += v[c]; v[c] = 0; } }
    else throw new Error('unknown batch operation ' + o.op);
    steps.push(JSON.parse(JSON.stringify(inv)));
  }
  const held = {}; for (const v of Object.values(inv)) for (const c of components) addAtoms(held, c, v[c]); for (const c of components) addAtoms(held, c, out[c]);
  let err = 0; for (const k of new Set([...Object.keys(charged), ...Object.keys(held)])) err = Math.max(err, Math.abs((charged[k] || 0) - (held[k] || 0)) / ((charged[k] || 0) + 1e-30));
  return { inventory: inv, discharged: out, atomError: err, steps };
}

In detail

Method
List scheduling: each step starts at the later of its predecessors' finish and the earliest free vessel of its class; cleaning time is added after the last step a batch spends in a vessel. Cycle time is the bottleneck class's work plus cleaning per batch divided by its number of vessels.
Number types
64-bit numbers; batch and task counts are integers.
Tolerances
1e-12 h (absolute) when comparing vessel start times; 1e-9 added or subtracted before hours are floored to days; -1e-9 mol (absolute) before a reaction is refused for going negative; 1e-30 added to the denominator of the atom error.
Limits
The whole-day tasks drop the hour within the day. Inventory amounts between -1e-9 mol and 0 are set to 0.

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