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.
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.
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.