Resources Numerical accuracy Calculation policy

Plant readings: units, comparison and trends

Converts live plant readings to SI units, compares them with the model, thins long trends for plotting and summarizes a series.

How the precision is kept

Readings are kept as 64-bit numbers and converted with one multiplication or one short expression. The pressure factor for psi is a rounded constant. Text shown on markers is rounded; the stored rows are not.

Open item. Use the full psi factor 6894.757293168361 Pa, as the relief module already does to ten digits.

Calculations under this policy

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

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

Reading to SI units

64-bit

Converts a temperature, pressure, flow or length reading to SI, including gauge pressures.

K = °C + 273.15; K = (°F − 32) × 5 ÷ 9 + 273.15; Pa = gauge × factor + atmosphere; else value × factor

psi and psig use the factor 6894.757, the exact value rounded to seven significant digits. kg/h, t/h and m³/h multiply by the stored reciprocal 1/3600 instead of dividing by 3600. Gauge units are refused unless the local atmospheric pressure is given.

  • process/env/historian.js
  • lines 28 to 34
  • CadCraft 0.8.2
export function toSI(value, unit, { atm = null } = {}) {
  if (typeof value !== 'number' || !Number.isFinite(value)) return { ok: false, reason: 'no number' };
  if (unit in GAUGE) { if (!(atm > 0)) return { ok: false, reason: 'gauge pressure needs the local atmospheric pressure' }; return { ok: true, value: value * GAUGE[unit] + atm, unit: 'Pa' }; }
  const u = UNITS[unit]; if (!u) return { ok: false, reason: 'unknown unit "' + str(unit, 20) + '"' };
  const siUnit = { K: 'K', C: 'K', degC: 'K', F: 'K', degF: 'K', Pa: 'Pa', kPa: 'Pa', bar: 'Pa', MPa: 'Pa', psi: 'Pa', 'kg/s': 'kg/s', 'kg/h': 'kg/s', 't/h': 'kg/s', 'mol/s': 'mol/s', 'm3/h': 'm3/s', '%': 'fraction', m: 'm', mm: 'm', fraction: 'fraction' }[unit];
  return { ok: true, value: (['C', 'degC'].includes(unit) ? value + 273.15 : ['F', 'degF'].includes(unit) ? (value - 32) * 5 / 9 + 273.15 : value * u[0]), unit: siUnit };
}

Marker text

64-bit

The value shown next to a tag on the diagram and the 3D model.

The text is the raw reading rounded to 4 significant digits with toPrecision. The row itself keeps the unrounded raw and SI values.

  • process/env/historian.js
  • lines 83 to 84
  • CadCraft 0.8.2
/** Marker data for the P&ID and the 3D model: one entry per row. */
export function overlay(rows) { return rows.map((r) => ({ target: r.target, text: r.value == null ? '--' : `${+r.raw.toPrecision(4)} ${r.unit}`, state: r.state, label: r.label })); }

Plant against model

64-bit

How far each plant reading is from the value the model gives.

deviation = (plant − model) ÷ |model| (÷ 1 when the model value is 0)

The stored deviation is unrounded. When the model value is 0 the result is the absolute difference, not a relative one. toPrecision(4) and toFixed(1) appear only in the message text.

  • process/env/historian.js
  • lines 86 to 95
  • CadCraft 0.8.2
/** modelValues { 'kind:id:prop': SI value } from the flowsheet; tol { rel: 0.05 }. → { rows, problems } */
export function compareModel(rows, modelValues, map, tol = {}) {
  const out = [], problems = [], rel = tol.rel ?? 0.05;
  for (const r of rows) {
    if (r.value == null || !r.target) continue; const key = `${r.target.kind}:${r.target.id}:${r.target.prop}`, mv = modelValues[key]; if (mv == null) continue;
    const dev = (r.value - mv) / (Math.abs(mv) || 1); out.push({ key, plant: r.value, model: mv, deviation: dev });
    if (Math.abs(dev) > rel) problems.push(problem('deviates', 'warning', `${r.label}: the plant reads ${r.value.toPrecision(4)} and the model says ${mv.toPrecision(4)} (${(dev * 100).toFixed(1)} %).`, [{ kind: r.target.kind, id: r.target.id }], { req: { op: '~', value: mv, unit: r.siUnit, tol: rel }, found: r.value }));
  }
  return { rows: out, problems };
}

Trend thinning

64-bit

Reduces a long series to at most a given number of points for plotting, keeping its shape.

per bucket: keep the sample with the largest triangle area against the previous kept point and the next bucket's mean

Only existing samples are kept; no value is changed or averaged into the output. The triangle areas use raw millisecond timestamps and values in 64-bit and only decide which sample is kept.

  • process/env/historian.js
  • lines 96 to 107
  • CadCraft 0.8.2
/** Largest-triangle-three-buckets downsampling of [{ t, value }] to at most maxPoints (keeps the first and last). */
export function trend(points, maxPoints = 300) {
  const p = points.filter((x) => x.t != null && x.value != null); if (p.length <= maxPoints || maxPoints < 3) return p;
  const out = [p[0]], every = (p.length - 2) / (maxPoints - 2); let a = 0;
  for (let i = 0; i < maxPoints - 2; i++) {
    const s = Math.floor((i + 1) * every) + 1, e = Math.min(Math.floor((i + 2) * every) + 1, p.length), ns = e, ne = Math.min(Math.floor((i + 3) * every) + 1, p.length);
    let ax = 0, ay = 0, n = 0; for (let j = ns; j < ne; j++) { ax += p[j].t; ay += p[j].value; n++; } if (!n) { ax = p[p.length - 1].t; ay = p[p.length - 1].value; n = 1; } ax /= n; ay /= n;
    let best = -1, bi = s; for (let j = s; j < e; j++) { const area = Math.abs((p[a].t - ax) * (p[j].value - p[a].value) - (p[a].t - p[j].t) * (ay - p[a].value)); if (area > best) { best = area; bi = j; } }
    out.push(p[bi]); a = bi;
  }
  out.push(p[p.length - 1]); return out;
}

Series summary

64-bit

Count, minimum, maximum, mean and standard deviation of a series.

mean = Σ v ÷ n; sd = √(Σ (v − mean)² ÷ n)

The mean is a straight left-to-right sum. The standard deviation divides by n (the population form) and is built from squared deviations about the mean, which avoids subtracting two large sums.

  • process/env/historian.js
  • line 108
  • CadCraft 0.8.2
export function summary(points) { const v = points.map((x) => x.value).filter((x) => x != null); if (!v.length) return { n: 0 }; const n = v.length, mean = v.reduce((a, b) => a + b, 0) / n; return { n, min: Math.min(...v), max: Math.max(...v), mean, sd: Math.sqrt(v.reduce((a, b) => a + (b - mean) ** 2, 0) / n) }; }

In detail

Method
Linear unit conversion; relative deviation; largest-triangle-three-buckets downsampling; mean and population standard deviation.
Number types
64-bit numbers; timestamps are integer milliseconds from Date.parse.
Tolerances
Deviation warning above 5 % relative by default (tol.rel). Readings older than 120 s are marked stale by default.
Limits
psi and psig use 6894.757 Pa, seven significant digits of the exact factor.

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