Files
claudetools/projects/dataforth-dos/datasheet-pipeline/implementation/templates/datasheet-exact.js
Mike Swanson 551b0c860f dataforth(datasheet): Fix 2 STAGE 2 — wire DSCA per-model templates into render
Deployed file is C:\Shares\testdatadb\templates\datasheet-exact.js; this
reconciles the repo copy + adds dsca-templates.json (STAGE 1 output).

What changed in generateExactDatasheet (DSCA family only; 5B/8B/7B/DSCT/SCMVAS
paths byte-unchanged):
- Load dsca-templates.json once at module top (126 per-model layouts).
- DSCA Final-Test now renders names + specs from the staged template rows, not
  the single hardcoded DATA_LINES['DSCA'] + buildTSpecs DSCA branch.
- Value-bearing raw_data STATUS groups map positionally onto the spec-bearing
  template rows; empty-spec rows (240VAC Withstand / Hi-Pot) render blank+PASS.
  Removed the duplicate hardcoded 240VAC/Hi-Pot footer for DSCA (now rows).
- ACCURACY header uses the template accOut ("Output (V)"/"Output (mA)") with '-'
  rule separators instead of "Vout (V)" + '='.
- Header/columns match the staged originals (Measured Value*, 25/15/19/6 rule).

Two real bugs fixed (both are the handoff's "lines drop" / wrong-value defect):
- formatMeasuredExact reads the value from index 4 so negative signs survive
  ("PASS-4.24..." -> "-4", not "4"); also decimal-code N -> toFixed(N) exactly
  (DSCA differs from 5B/8B where code 2 means 1 decimal).
- parseRawData no longer consumes the first DSCA STATUS group as a bare
  step-response line when that line is absent (dropped 3 rows on e.g. DSCA39-01).

Safety: when value count != spec-row count the positional zip is ambiguous
(subtype measures load points the template omits, e.g. DSCA49 5mA pair), so the
cert is SKIPPED (null) and left for STAGE 3 per-subtype mapping rather than
emitting misaligned data.

Validation: DSCA38-05 (SN 180224-1) Final-Test block byte-identical to its
staged original. 92/126 templated models render cleanly; 7 ambiguous + 27
no-spec skip. Remaining ACCURACY-block spacing diffs are the deferred cosmetic
gap. NOT YET LIVE: testdatadb service not restarted, nothing re-pushed to
Hoffman (STAGE 3 gate).

Coverage gap to resolve before publish: only 126/357 DSCA models in the DB have
a staged template (56,074 certs, 70.1%); 231 models / 23,866 certs have none and
now render null — needs a STAGE 1 extension (more staged originals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:02:33 -07:00

1022 lines
42 KiB
JavaScript

/**
* Exact-Match Datasheet Formatter
*
* Generates TXT datasheets matching the original QuickBASIC DATASHEETWRITE output.
* Requires a DB record (with raw_data) and model specs from spec-reader.
*/
const { getFamily } = require('../parsers/spec-reader');
// DSCA per-model Final-Test templates (Fix 2 STAGE 1 output). Each entry is
// { accOut: 'Output (V)'|'Output (mA)', rows: [{name, spec}, ...] }, extracted
// byte-accurately from the staged originals. This is the AUTHORITATIVE source
// of DSCA parameter names + specs + accuracy output label; loaded once.
let DSCA_TEMPLATES = {};
try {
DSCA_TEMPLATES = require('../dsca-templates.json');
} catch (e) {
DSCA_TEMPLATES = {};
}
// -------------------------------------------------------------------------
// DATA LINES: parameter names and units per family
// -------------------------------------------------------------------------
const DATA_LINES = {
SCM5B: [
['Supply Current, Nom', 'mA'], // 1
['Supply Current, Max', 'mA'], // 2
['Exc. Current #1', 'uA'], // 3
['Exc. Current #2', 'uA'], // 4
['Exc. Current Match', 'uA'], // 5
['Output Resistance', 'ohms'], // 6
['CJC Gain', 'uV/C'], // 7
['Exc. Voltage', 'V'], // 8
['Exc. Load Reg.', 'ppm/mA'], // 9
['Vout Reg. w/ Load', '%'], // 10
['Exc. Current Limit', 'mA'], // 11
['Linearity', '%'], // 12
['Accuracy', '%'], // 13
['Lead R Effect', 'C/ohm'], // 14
['Supply Sensitivity', 'uV/%'], // 15
['Input Resistance', 'Mohms'], // 16
['Open Input Response', 'V'], // 17
['Frequency Response', 'dB'], // 18
['Step Response', '%'], // 19
['Output Noise', 'uVrms'], // 20
['Over-range Response', 'V'], // 21
],
'8B': [
['Supply Current, Nom', 'mA'],
['Supply Current, Max', 'mA'],
['Exc. Current #1', 'uA'],
['Exc. Current #2', 'uA'],
['Exc. Current Match', 'uA'],
['Output Resistance', 'ohms'],
['CJC Gain', 'uV/C'],
['Exc. Voltage', 'V'],
['Exc. Load Reg.', 'ppm/mA'],
['Vout Reg. w/ Load', '%'],
['Exc. Current Limit', 'mA'],
['Linearity', '%'],
['Accuracy', '%'],
['Lead R Effect', 'C/ohm'],
['Supply Sensitivity', 'ppm/%'],
['Input Resistance', 'Mohms'],
['Open Input Response', 'V'],
['Frequency Response', 'dB'],
['Step Response', '%'],
['Output Noise', 'uVrms'],
['Over-range Response', 'V'],
],
DSCA: [
['Supply Current, Nom', 'mA'],
['Supply Current @ Max Load', 'mA'],
['Linearity, 0mA Load', '%'],
['Accuracy, 0mA Load', '%'],
['Linearity, 5mA Load', '%'],
['Accuracy, 5mA Load', '%'],
['Linearity, 50mA Load', '%'],
['Accuracy, 50mA Load', '%'],
['Positive Current Limit', 'mA'],
['Negative Current Limit', 'mA'],
['Overrange', '%'],
['Power Supply Sensitivity', '%/%'],
['Input Resistance', 'Mohms'],
['Frequency Response', 'dB'],
['Step Response', '%'],
['Output Noise', ''],
['Compliance', '%'],
['Accuracy @ 5 ohm load', '%'],
],
SCM7B: [
['Supply Current', 'mA'], // 1
['Supply Current w/ Load', 'mA'], // 2
['Bias Current', 'nA'], // 3
['Input Resistance', 'kohms'], // 4
['Offset Calibration', 'mV'], // 5
['Gain Calibration', 'mV'], // 6
['Linearity/Conformity', '%'], // 7
['Accuracy', '%'], // 8
['VLoop @ 0 mA (Vs = 18V)', 'V'], // 9
[' (Vs = 35V)', 'V'], // 10
['VLoop @ 4 mA (Vs = 18V)', 'V'], // 11
[' (Vs = 35V)', 'V'], // 12
['VLoop @ 20mA (Vs = 18V)', 'V'], // 13
[' (Vs = 35V)', 'V'], // 14
['VLoop Peak Ripple', 'mV'], // 15
['High Excitation Current', 'uA'], // 16
['Low Excitation Current', 'uA'], // 17
['Output Effective Power', 'mW'], // 18
['Supply Sensitivity', '%/%Vs'], // 19
['Open Sensor Response', 'V'], // 20
['Lead Resistance Effect', 'C/ohm'], // 21
['CJC Gain', 'uV/C'], // 22
['100kHz Output Noise', 'uVrms'], // 23
['Attenuation', 'dB'], // 24
['150ms Step Response', 'V'], // 25
['Output Noise', 'mVpk'], // 26
['Over-Range', 'V'], // 27
['Under-Range', 'V'], // 28
['Open Loop Detect', 'mA'], // 29
['Error @ Max Rload', '%'], // 30
['Pass-Through Error', '%'], // 31
],
SCMVAS: [
['Accuracy', '%'],
],
DSCT: [
['Under-range Limit', 'mA'],
['Over-range Limit', 'mA'],
['Error @ Vloop = 10.8V', '%'],
['Error @ Vloop = 60V', '%'],
['Minus f.s. Exc. Current', 'uA'],
['Plus f.s. Exc. Current', 'uA'],
['Current Source Matching', '%'],
['Linearity / Conformity', '%'],
['Accuracy', '%'],
['Lead Resistance Effects', 'C/ohm'],
['Loop Voltage Sensitivity', '%/V'],
['Input Resistance', 'Mohm'],
['Open Thermocouple Response', 'mA'],
['Frequency Response', 'dB'],
['Step Response', '%'],
['Output Noise', 'uArms'],
],
};
// -------------------------------------------------------------------------
// Sensor type number mapping (for input column headers)
// -------------------------------------------------------------------------
function getSensorNum(sentype) {
if (!sentype) return 1;
const s = sentype.toUpperCase().trim();
if (s === 'V' || s === 'MV') return 1;
if (s === 'MA') return 2;
if (s.includes('JTC') || s === 'J') return 3;
if (s.includes('KTC') || s === 'K') return 4;
if (s.includes('TTC') || s === 'T') return 5;
if (s.includes('ETC') || s === 'E' || s.includes('RTC') || s.includes('STC') || s.includes('NTC') || s.includes('BTC')) return 6;
if (s.includes('RTD')) return 7;
if (s === 'FBRIDGE' || s === 'HBRIDGE') return 8;
if (s === '2WTX') return 9;
return 1; // default voltage
}
// -------------------------------------------------------------------------
// Parse raw_data from DB record
// -------------------------------------------------------------------------
function parseRawData(rawData, family) {
if (!rawData) return null;
const lines = rawData.split('\n').map(l => l.trim()).filter(l => l.length > 0);
if (lines.length < 8) return null;
const result = {
modelLine: '',
accuracy: [], // 5 points: { stim, calc, meas, error, status }
stepResponse: 0,
statusEntries: [],
};
let lineIdx = 0;
// Line 0: model name (quoted)
result.modelLine = lines[lineIdx++].replace(/"/g, '').trim();
// Lines 1-5: accuracy points
for (let i = 0; i < 5 && lineIdx < lines.length; i++) {
const parts = parseCSVLine(lines[lineIdx++]);
if (parts.length >= 5) {
result.accuracy.push({
stim: parseFloat(parts[0]),
calc: parseFloat(parts[1]),
meas: parseFloat(parts[2]),
error: parseFloat(parts[3]),
status: parts[4].replace(/"/g, '').trim(),
});
}
}
// Next line: step response / placeholders.
// SCM5B/8B: "0","0",value DSCT: just value. Many DSCA models OMIT this bare
// line and go straight to the STATUS groups; consuming a STATUS group here
// drops a Final-Test row (the "lines drop" defect). For DSCA, skip consuming
// when the line is actually a STATUS group (starts with PASS/FAIL).
if (lineIdx < lines.length) {
const looksLikeStatus = /^"?(PASS|FAIL)/i.test(lines[lineIdx].trim());
if (!(family === 'DSCA' && looksLikeStatus)) {
const parts = parseCSVLine(lines[lineIdx++]);
const lastVal = parts[parts.length - 1];
result.stepResponse = parseFloat(lastVal) || 0;
}
}
// Remaining lines: STATUS groups
// SCM5B/8B: groups of 5, DSCT: groups of 4
const groupSize = (family === 'DSCT') ? 4 : 5;
while (lineIdx < lines.length) {
const line = lines[lineIdx];
// Stop if we hit the serial/date line
if (line.match(/^"\d+-\d+[A-Za-z]?","/)) break;
const parts = parseCSVLine(line);
for (const p of parts) {
result.statusEntries.push(p.replace(/"/g, ''));
}
lineIdx++;
}
return result;
}
// Simple CSV parser that handles quoted strings
function parseCSVLine(line) {
const parts = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
parts.push(current.trim());
current = '';
} else {
current += ch;
}
}
parts.push(current.trim());
return parts;
}
// -------------------------------------------------------------------------
// Format measured value from STATUS entry
// -------------------------------------------------------------------------
/**
* Format a number matching QuickBASIC STR$() behavior:
* - Positive numbers get a leading space
* - Leading zeros before decimal are dropped (0.03 -> .03)
* - Rounds to 6 significant digits to clean IEEE 754 artifacts
*/
function r(val, fixedDecimals) {
if (val == null || isNaN(val)) return '0';
const rounded = parseFloat(val.toPrecision(6));
let str;
if (fixedDecimals != null) {
str = rounded.toFixed(fixedDecimals);
} else {
str = String(rounded);
}
// QB STR$() drops leading zero: "0.03" -> ".03"
str = str.replace(/^0\./, '.').replace(/^-0\./, '-.');
// QB STR$() prepends space for positive numbers
if (rounded >= 0 && !str.startsWith(' ')) {
str = ' ' + str;
}
return str;
}
/**
* Parse STATUS$ entry and format measured value matching QB PRINT USING.
* QB format strings all produce exactly 6 characters for the number:
* "0" -> "###### &" (integer, 6 digits)
* "1" -> "####.# &" (1 decimal, 6 chars)
* "2" -> "####.# &" (same as 1)
* "3" -> "##.### &" (3 decimals, 6 chars)
* "4" -> "#.#### &" (4 decimals, 6 chars)
*/
function formatMeasured(statusStr) {
if (!statusStr || statusStr.length <= 4) return null;
const passFail = statusStr.substring(0, 4); // "PASS" or "FAIL"
const decimalDigit = statusStr[statusStr.length - 1];
const valueStr = statusStr.substring(5, statusStr.length - 1).trim();
const value = parseFloat(valueStr);
if (isNaN(value)) return { passFail, formatted: valueStr, width: 6 };
// QB PRINT USING: right-justified in 6 character positions
// Negative sign takes one digit position
let formatted;
switch (decimalDigit) {
case '0': formatted = Math.round(value).toString().padStart(6); break;
case '1': formatted = value.toFixed(1).padStart(6); break;
case '2': formatted = value.toFixed(1).padStart(6); break;
case '3': formatted = value.toFixed(3).padStart(6); break;
case '4': formatted = value.toFixed(4).padStart(6); break;
default: formatted = value.toFixed(1).padStart(6); break;
}
return { passFail, formatted, value };
}
/**
* DSCA measured-value formatter. The DSCA Final-Test STATUS$ entries use the
* trailing digit as a literal decimal-place count (code N -> toFixed(N)),
* UNLIKE the 5B/8B QB format strings where code 2 means 1 decimal. Returns the
* trimmed value string (caller column-aligns it) plus the PASS/FAIL prefix.
*/
function formatMeasuredExact(statusStr) {
if (!statusStr || statusStr.length <= 4) return null;
const passFail = statusStr.substring(0, 4);
const decimalDigit = statusStr[statusStr.length - 1];
// char at index 4 is either a space (positive) or '-' (negative); start there
// so negative signs survive (e.g. "PASS-4.2424060" -> "-4", not "4").
const valueStr = statusStr.substring(4, statusStr.length - 1).trim();
const value = parseFloat(valueStr);
if (isNaN(value)) return { passFail, valStr: valueStr, value: NaN };
const d = parseInt(decimalDigit, 10);
const valStr = isNaN(d) ? value.toFixed(1) : value.toFixed(d);
return { passFail, valStr, value };
}
/**
* Split a DSCA template spec string into its value part and trailing unit.
* e.g. "< 30 mA" -> { valuePart: "< 30", unit: "mA" } (internal spacing kept,
* so the value part can be right-aligned to match the staged column layout).
* "+/- 11 ppm/mA" -> { valuePart: "+/- 11", unit: "ppm/mA" }.
*/
function splitSpecUnit(spec) {
const s = String(spec);
const m = s.match(/^(.*\S)\s+(\S+)$/);
if (m) return { valuePart: m[1], unit: m[2] };
return { valuePart: s.trim(), unit: '' };
}
// -------------------------------------------------------------------------
// Format TSPEC display string from spec values
// -------------------------------------------------------------------------
function buildTSpecs(specs, family, stepResponse) {
if (!specs) return [];
const tspecs = [];
if (family === 'SCM5B' || family === '8B') {
tspecs[1] = ' < ' + r(specs.ISMAXNEXCL);
tspecs[2] = ' < ' + r(specs.ISMAXFEXCL);
tspecs[3] = ' ' + r(specs.IEXC);
tspecs[4] = ' ' + r(specs.IEXC);
const imatchtol = (specs.IMATCHTOL || 0) / 100;
tspecs[5] = '+/-' + r(specs.IEXC * imatchtol, 0);
tspecs[6] = family === '8B' ? ' < 50' : ' < ' + r(specs.OUTRES || 55);
tspecs[7] = ''; // CJC gain - computed from polynomial, skip for now
if (specs.VEXC) {
const vexcAcc = Math.round(specs.VEXCACC / 100 * specs.VEXC * 1000) / 1000;
tspecs[8] = r(specs.VEXC, 1) + '+/-' + r(vexcAcc, 3);
} else {
tspecs[8] = '';
}
tspecs[9] = '+/-' + r(specs.EXCLOADREG);
const acc125 = Math.round((specs.ACCURACY * 1.25) * 100) / 100;
tspecs[10] = '+/-' + r(acc125);
tspecs[11] = ' < ' + r(specs.EXCIMAX);
tspecs[12] = '+/-' + r(specs.LINEAR);
tspecs[13] = '+/-' + r(specs.ACCURACY);
tspecs[14] = '+/-' + r(stepResponse || 0, 1);
tspecs[15] = '+/-' + r(specs.PSS || 0);
tspecs[16] = ' >=' + r(specs.INPUTRES);
if (specs.VOPENINMIN != null && specs.VOPENINMAX != null) {
tspecs[17] = r(specs.VOPENINMIN, 2) + ' to ' + r(specs.VOPENINMAX, 2);
} else {
tspecs[17] = '';
}
tspecs[18] = r(specs.ATTEN) + '+/-' + r(specs.ATTENTOL);
tspecs[19] = r(specs.STEPRMIN || 0) + ' to ' + r(specs.STEPRMAX || 0);
tspecs[20] = ' < ' + r(specs.OUTNOISE);
tspecs[21] = tspecs[17]; // duplicate
} else if (family === 'DSCA') {
tspecs[1] = ' < ' + r(specs.ISMAXNL || 0);
tspecs[2] = ' < ' + r(specs.ISMAXFL || 0);
tspecs[3] = '+/-' + r(specs.LINEAR1 || 0);
tspecs[4] = '+/-' + r(specs.ACCURACY1 || 0);
tspecs[5] = '+/-' + r(specs.LINEAR2 || 0);
tspecs[6] = '+/-' + r(specs.ACCURACY2 || 0);
tspecs[7] = '+/-' + r(specs.LINEAR3 || 0);
tspecs[8] = '+/-' + r(specs.ACCURACY3 || 0);
tspecs[9] = ' < ' + r(specs.ILIMIT || 0);
tspecs[10] = ' > ' + r(-(specs.ILIMIT || 0));
tspecs[11] = ' > ' + r(specs.PERCOVER || 0);
tspecs[12] = '+/-' + r(specs.PSS || 0);
tspecs[13] = ' >=' + r(specs.INPUTRES || 0);
tspecs[14] = r(specs.ATTEN || 0) + '+/-' + r(specs.ATTENTOL || 0);
tspecs[15] = r(specs.STEPRMIN || 0) + ' to ' + r(specs.STEPRMAX || 0);
tspecs[16] = ' <=' + r(specs.OUTNOISE || 0);
tspecs[17] = '+/-' + r(specs.COMPLIANCE || 0);
tspecs[18] = '+/-' + r((specs.ACCURACY1 || 0) * 2);
} else if (family === 'DSCT') {
tspecs[1] = ''; // computed at runtime
tspecs[2] = ''; // computed at runtime
tspecs[3] = ' < 1';
tspecs[4] = ' < 1';
const iexcmTol = specs.MODNAME && specs.MODNAME.startsWith('DSCT') ? 0.05 : 0.02;
tspecs[5] = Math.round(specs.IEXCMFS || 0) + '+/-' + Math.round((specs.IEXCMFS || 0) * iexcmTol);
tspecs[6] = Math.round(specs.IEXCPFS || 0) + '+/-' + Math.round((specs.IEXCPFS || 0) * iexcmTol);
tspecs[7] = '+/-' + r(specs.IMATCHTOL || 0);
tspecs[8] = '+/- ' + r(specs.LINEAR || 0);
tspecs[9] = '+/- ' + r(specs.ACCURACY || 0);
tspecs[10] = '+/-' + r(stepResponse || 0, 1);
tspecs[11] = '+/-' + r(specs.VSEN || 0);
tspecs[12] = ' >=' + r(specs.INPUTRES || 0);
const iopentc = specs.IOPENTC || 0;
const maxout = specs.MAXOUT || 20;
tspecs[13] = (iopentc > maxout ? ' > ' : ' < ') + r(iopentc);
tspecs[14] = r(specs.ATTEN || 0) + '+/-' + r(specs.ATTENTOL || 0);
tspecs[15] = r(specs.STEPRMIN || 0) + ' to ' + r(specs.STEPRMAX || 0);
tspecs[16] = ' < ' + r(specs.OUTNOISE || 0);
} else if (family === 'SCM7B') {
const orange = (specs.MAXOUT || 5) - (specs.MINOUT || 0);
tspecs[1] = '< ' + r(specs.ISMAXNEXCL + 6);
tspecs[2] = '< ' + r(specs.ISMAXFEXCL + 6);
tspecs[3] = '+/-' + r(specs.IBIAS || 0);
tspecs[4] = ' > ' + r(specs.INPUTRES || 0);
const calTol = 20 * orange * (specs.CALTOL || 0);
tspecs[5] = '+/-' + r(calTol);
tspecs[6] = '+/-' + r(calTol);
tspecs[7] = '+/-' + r(specs.LINEAR || 0);
tspecs[8] = '+/-' + r(specs.ACCURACY || 0);
if (specs.VEXC) {
const vexc5 = specs.VEXC * 0.05;
tspecs[9] = r(specs.VEXC) + ' +/-' + r(vexc5);
tspecs[10] = tspecs[9];
}
if (specs.VEXCLO) {
const vlo5 = specs.VEXCLO * 0.05;
tspecs[11] = r(specs.VEXCLO) + ' +/-' + r(vlo5);
tspecs[12] = tspecs[11];
}
if (specs.VEXCHI) {
const vhi5 = specs.VEXCHI * 0.05;
tspecs[13] = r(specs.VEXCHI) + ' +/-' + r(vhi5);
tspecs[14] = tspecs[13];
}
tspecs[15] = ' < 50';
tspecs[16] = ' < ' + r(specs.EXCIMAX || 0);
tspecs[17] = ' > ' + r(specs.EXCIMIN || 0);
tspecs[18] = ' > ' + r(specs.PE || 0);
tspecs[19] = '+/-' + r(specs.PSS || 0);
tspecs[20] = ''; // Open TC - needs runtime calc
tspecs[21] = '+/-' + r(specs.LEADRERR || 0);
tspecs[22] = ''; // CJC - needs seebeck polynomial
tspecs[23] = ' < ' + r(specs.OUTNOISERMS || 0);
tspecs[24] = r(specs.ATTEN || 0) + '+/-' + r(specs.ATTENTOL || 0);
// Step response
if (specs.STEPRESP && specs.STEPTOL) {
const lowV = specs.STEPRESP - specs.STEPTOL;
const highV = specs.STEPRESP + specs.STEPTOL;
tspecs[25] = r(lowV) + ' to ' + r(highV);
} else {
tspecs[25] = '';
}
tspecs[26] = ' < ' + r(specs.OUTNOISEVPK || 0);
tspecs[27] = '+5 to +5.8';
tspecs[28] = '-.9 to +1';
tspecs[29] = '0';
tspecs[30] = ''; // Compliance - needs runtime calc
tspecs[31] = '+/-' + r(specs.ACCURACY || 0);
}
return tspecs;
}
// -------------------------------------------------------------------------
// Format accuracy value based on sensor type
// -------------------------------------------------------------------------
function formatAccuracyLine(point, sensorNum, maxIn) {
let stimStr;
if ((sensorNum >= 3 && sensorNum <= 6) || sensorNum === 7) {
// Temperature: +####.##
stimStr = formatSigned(point.stim, 2, 8);
} else {
// Voltage/Current: +###.###
const scale = (maxIn != null && maxIn < 1) ? 1000 : 1;
stimStr = formatSigned(point.stim * scale, 3, 8);
}
const calcStr = formatSigned(point.calc, 3, 7);
const measStr = formatSigned(point.meas, 3, 7);
const errorStr = formatSigned(point.error, 3, 8);
return ' ' + stimStr + ' ' + calcStr + ' ' + measStr + ' ' + errorStr + ' ' + point.status;
}
/**
* Set text at a specific column position (0-indexed) in a string.
* Pads with spaces if the string is shorter than the target column.
*/
function setCol(str, col, text) {
while (str.length < col) str += ' ';
return str + text;
}
/**
* Pad string to reach a column position (for inline TAB simulation).
* Returns spaces needed to reach the column from current position.
*/
function padToCol(str, col) {
const needed = col - str.length;
return needed > 0 ? ' '.repeat(needed) : ' ';
}
function formatSigned(val, decimals, width) {
const sign = val >= 0 ? '+' : '';
const str = sign + val.toFixed(decimals);
return str.padStart(width);
}
// -------------------------------------------------------------------------
// Main: generate exact-match TXT datasheet
// -------------------------------------------------------------------------
/**
* Generate an exact-match TXT datasheet from a DB record and model specs.
* @param {object} record - DB record with raw_data, model_number, serial_number, test_date
* @param {object} specs - Model spec record from spec-reader
* @returns {string|null} Formatted TXT datasheet, or null if data is insufficient
*/
function generateExactDatasheet(record, specs) {
const family = getFamily(record.model_number);
if (!family) return null;
if (family === 'SCMVAS') {
return generateSCMVASDatasheet(record);
}
// DSCA STAGE 2: the per-model staged template is the authoritative Final-Test
// layout (names + specs + accuracy output label). No template means no staged
// original was available -> do not guess the layout; skip this cert.
const dscaTpl = (family === 'DSCA')
? (DSCA_TEMPLATES[(record.model_number || '').trim()] || null)
: null;
if (family === 'DSCA' && !dscaTpl) return null;
const parsed = (family === 'SCM7B')
? parse7BRawData(record.raw_data)
: parseRawData(record.raw_data, family);
if (!parsed) return null;
if (family !== 'SCM7B' && parsed.accuracy.length < 5) return null;
const dataLines = DATA_LINES[family];
if (!dataLines) return null;
const sentype = specs ? specs.SENTYPE : '';
const sensorNum = getSensorNum(sentype);
const maxIn = specs ? specs.MAXIN : 10;
const tspecs = specs ? buildTSpecs(specs, family, parsed.stepResponse) : [];
// Format test date from YYYY-MM-DD to MM-DD-YYYY
const dateParts = (record.test_date || '').split('-');
const dateStr = dateParts.length === 3
? `${dateParts[1]}-${dateParts[2]}-${dateParts[0]}`
: record.test_date || '';
let modelName = specs ? specs.MODNAME : record.model_number;
// 7B header prepends "SCM" to the model name
if (family === 'SCM7B' && !modelName.toUpperCase().startsWith('SCM')) {
modelName = 'SCM' + modelName;
}
const lines = [];
const TAB5 = ' '; // 4 spaces = TAB(5) in QB (0-indexed)
// ---- Header ----
lines.push(TAB5 + 'DATAFORTH CORPORATION Phone: (520) 741-1404');
lines.push(TAB5 + '3331 E. Hemisphere Loop Fax: (520) 741-0762');
lines.push(TAB5 + 'Tucson, AZ 85706 USA email: info@dataforth.com');
lines.push('');
lines.push(' TEST DATA SHEET');
lines.push(TAB5 + '~'.repeat(71));
// QB: PRINT #9, TAB(5); "Date: "; DATE$
// PRINT #9, TAB(5); "Model: "; SPECS.MODNAME
// PRINT #9, TAB(5); "SN: "; TAB(12); SN$
lines.push(TAB5 + 'Date: ' + dateStr);
lines.push(TAB5 + 'Model: ' + modelName);
let snLine = TAB5 + 'SN: ';
snLine = setCol(snLine, 11, record.serial_number); // TAB(12) = index 11
lines.push(snLine);
lines.push('');
// ---- Accuracy Test ----
// 7B CSV format doesn't include individual accuracy test points (only error pcts in LOGIT)
// The accuracy data is only in the SHT files, not the DAT files
if (family === 'SCM7B') {
// Skip accuracy section entirely for 7B — data not available from DAT format
} else {
lines.push(' ACCURACY TEST');
lines.push('');
lines.push(' Calculated Measured');
// Input column header based on sensor type
let inputHeader;
if ((sensorNum >= 3 && sensorNum <= 6) || sensorNum === 7) {
inputHeader = ' Temp. (C)';
} else if (sensorNum === 2 || sensorNum === 9) {
inputHeader = ' Iin (mA)';
} else {
inputHeader = (maxIn != null && maxIn < 1) ? ' Vin (mV)' : ' Vin (V)';
}
// DSCA labels its accuracy output column "Output (V)"/"Output (mA)" (from the
// template) with '-' rule separators; 5B/8B/etc. use "Vout (V)" with '='.
const accOut = (family === 'DSCA' && dscaTpl) ? dscaTpl.accOut : 'Vout (V)';
const accSep = (family === 'DSCA') ? '-' : '=';
lines.push(' ' + inputHeader + ' ' + accOut + ' ' + accOut + '* Error (%) Status');
lines.push(TAB5 + accSep.repeat(10) + ' ' + accSep.repeat(10) + ' ' + accSep.repeat(10) + ' ' + accSep.repeat(9) + ' ' + accSep.repeat(8));
for (const point of parsed.accuracy) {
lines.push(formatAccuracyLine(point, sensorNum, maxIn));
}
lines.push('');
} // end accuracy section conditional
// ---- Final Test Results ----
// QB column positions (1-indexed): TAB(31), TAB(47), TAB(60-speclen), TAB(61), TAB(71)
lines.push(' FINAL TEST RESULTS');
lines.push('');
if (family === 'DSCA') {
// DSCA Final-Test renders from the per-model staged template: the rows give
// the parameter names + specs (and accuracy label) directly; the value-bearing
// raw_data STATUS groups map positionally onto the spec-bearing rows. Rows with
// an empty spec (240VAC Withstand / Hi-Pot) carry no measured value and render
// as PASS. Header/column scheme matches the staged originals.
// Value-bearing measurements in source order (drop "PASS"/"" padding entries).
const measurements = [];
for (const s of parsed.statusEntries) {
const m = formatMeasuredExact(s);
if (m) measurements.push(m);
}
const specRowCount = dscaTpl.rows.filter(r => (r.spec || '').trim()).length;
// The simple positional zip is only sound when there is exactly one measured
// value per spec-bearing row. When counts differ, this subtype uses a slot
// layout the zip can't resolve (e.g. raw_data records load points the template
// omits), so emitting would misalign values onto wrong rows. Skip and flag for
// STAGE 3 per-subtype mapping rather than publish wrong data ("do not guess").
if (measurements.length !== specRowCount) return null;
let h1 = setCol('', 12, 'Parameter');
h1 = setCol(h1, 31, 'Measured Value*');
h1 = setCol(h1, 51, 'Specification');
h1 = setCol(h1, 69, 'Status');
lines.push(h1);
let h2 = setCol('', 4, '='.repeat(25));
h2 = setCol(h2, 31, '='.repeat(15));
h2 = setCol(h2, 48, '='.repeat(19));
h2 = setCol(h2, 69, '='.repeat(6));
lines.push(h2);
let mi = 0;
for (const row of dscaTpl.rows) {
const spec = (row.spec || '').trim();
let line = setCol('', 4, row.name);
if (spec) {
const su = splitSpecUnit(spec);
const m = measurements[mi++];
if (m) {
// measured value right-justified ending at col 38, unit at col 40
const v = String(m.valStr);
line = setCol(line, 39 - v.length, v);
if (su.unit) line = setCol(line, 40, su.unit);
}
// spec value-part right-justified ending at col 58, unit at col 60
line = setCol(line, 59 - su.valuePart.length, su.valuePart);
if (su.unit) line = setCol(line, 60, su.unit);
line = setCol(line, 70, m ? m.passFail : 'PASS');
} else {
// no spec => 240VAC Withstand / Hi-Pot style row: blank measured + PASS
line = setCol(line, 70, 'PASS');
}
lines.push(line);
}
} else {
// QB: TAB(12); "Parameter"; TAB(30); "Measured Value"; TAB(51); "Specification "; TAB(70); "Status"
let hdr1 = setCol('', 11, 'Parameter');
hdr1 = setCol(hdr1, 29, 'Measured Value');
hdr1 = setCol(hdr1, 50, 'Specification ');
hdr1 = setCol(hdr1, 69, 'Status');
lines.push(hdr1);
// QB: TAB(5); "======================="; TAB(30); "==============="; TAB(47); "====================="; TAB(70); "======"
let hdr2 = setCol('', 4, '=======================');
hdr2 = setCol(hdr2, 29, '===============');
hdr2 = setCol(hdr2, 46, '=====================');
hdr2 = setCol(hdr2, 69, '======');
lines.push(hdr2);
for (let i = 0; i < dataLines.length && i < parsed.statusEntries.length; i++) {
const status = parsed.statusEntries[i];
if (!status || status.length <= 4) continue; // Skip if no measured data
const [paramName, paramUnit] = dataLines[i];
let unit = paramUnit;
// Unit overrides per QB logic
if (family === 'SCM5B' || family === '8B') {
if (i === 13 && sensorNum === 7) unit = 'ohm/ohm';
if (i === 14 && (sensorNum === 5 || sensorNum === 6)) unit = 'C/V';
}
const measured = formatMeasured(status);
if (!measured) continue;
// Build line matching QB TAB positions (converting to 0-indexed for string ops)
// TAB(5): parameter name
// TAB(31): measured value (6 chars right-justified) + space + unit
// TAB(60-speclen): spec string right-aligned to end at col 60
// TAB(61): unit
// TAB(71): PASS/FAIL
let line = '';
line = setCol(line, 4, paramName); // TAB(5) = index 4
line = setCol(line, 30, measured.formatted + ' ' + unit); // TAB(31) = index 30
const tspec = tspecs[i + 1]; // 1-indexed in TSPECS
if (tspec) {
const specLen = tspec.length;
line = setCol(line, 59 - specLen, tspec); // TAB(60-speclen)
line = setCol(line, 60, unit); // TAB(61) = index 60
}
line = setCol(line, 70, measured.passFail); // TAB(71) = index 70
lines.push(line);
}
} // end non-DSCA Final Test Results
// ---- Footer ----
// 240 VAC / Hi-Pot (conditional by family/model)
if (family === 'SCM5B') {
const mn = (modelName || '').trim();
if (!mn.startsWith('SCM5BPT') && !mn.startsWith('SCM5B-1369')) {
lines.push(TAB5 + '240 VAC Withstand' + ''.padEnd(49) + 'PASS');
lines.push(TAB5 + 'Hi-Pot' + ''.padEnd(60) + 'PASS');
}
} else if (family === '8B') {
const mn = (modelName || '').trim();
if (!mn.startsWith('8BPT')) {
lines.push(TAB5 + 'VAC Withstand' + ''.padEnd(53) + 'PASS');
lines.push(TAB5 + 'Hi-Pot' + ''.padEnd(60) + 'PASS');
}
} else if (family === 'SCM7B') {
const mn = (modelName || '').toUpperCase();
if (!mn.includes('7BPT')) {
let vac = setCol(TAB5 + '120VAC Withstand', 70, 'PASS');
lines.push(vac);
let hp = setCol(TAB5 + 'Hi-Pot', 70, 'PASS');
lines.push(hp);
}
} else if (family === 'DSCT') {
lines.push(TAB5 + '240 VAC Withstand' + ''.padEnd(49) + 'PASS');
lines.push(TAB5 + 'Hi-Pot' + ''.padEnd(60) + 'PASS');
}
// Underline + Check List
lines.push(TAB5 + '_'.repeat(71));
if (family === 'SCM7B') {
lines.push(' Packing Check List');
lines.push('');
lines.push(setCol(TAB5 + 'Module Appearance: _____', 44, 'Mounting Screw: _____'));
lines.push('');
lines.push(setCol(TAB5 + 'Pins Straight: _____', 44, 'Module Header: _____'));
lines.push('');
lines.push(setCol(TAB5 + 'Tested by: _____________', 44, 'QC: _______________'));
} else if (family !== 'DSCA') {
lines.push(' Check List');
lines.push('');
lines.push(setCol(TAB5 + 'Module Appearance: __X__', 44, 'Mounting Screw: __X__'));
lines.push('');
lines.push(setCol(TAB5 + 'Pins Straight: __X__', 44, 'Module Header: __X__'));
}
// DSCA current output load note
if (family === 'DSCA' && specs && specs.OUTSIGTYPE && specs.OUTSIGTYPE.trim().toUpperCase() === 'CURRENT') {
lines.push(TAB5 + 'Standard output load for test is 250 ohms.');
}
lines.push('');
lines.push(TAB5 + 'It is hereby certified that the above product is in conformance with');
lines.push(TAB5 + 'all requirements to the extent specified. This product is not');
lines.push(TAB5 + 'authorized or warranted for use in life support devices and/or systems.');
lines.push('');
lines.push(TAB5 + '* NIST traceable calibration certificates support Measured Value data.');
lines.push(TAB5 + ' Calibration services are available through ANSI/NCSL Z540-1 and');
lines.push(TAB5 + ' ISO Guide 25 Certified Metrology Labs.');
lines.push('');
return lines.join('\r\n');
}
/**
* Parse 7B raw_data (single CSV line format)
* Format: STAGE: MODEL,SN,DATE,VERSION,DMMSERIAL,val1,...val31,err1,...errN
* val=9999 means not tested, [val] means FAIL
*/
function parse7BRawData(rawData) {
if (!rawData) return null;
const match = rawData.match(/^([A-Z-]+):\s*(.*)$/);
if (!match) return null;
const parts = match[2].split(',');
if (parts.length < 36) return null; // model + sn + date + version + dmmserial + 31 values minimum
const result = {
modelLine: parts[0].trim(),
accuracy: [],
stepResponse: 0,
statusEntries: [],
};
// Values start at index 5 (after model, sn, date, version, dmmserial)
for (let i = 0; i < 31; i++) {
const rawVal = (parts[5 + i] || '').trim();
if (rawVal === '9999' || rawVal === '') {
// Not tested - push short "PASS" (will be skipped by formatter)
result.statusEntries.push('PASS');
} else if (rawVal.startsWith('[')) {
// FAIL - bracketed value
const val = rawVal.replace(/[\[\]]/g, '').trim();
const numVal = parseFloat(val);
if (isNaN(numVal) || numVal === 0) {
result.statusEntries.push('FAIL');
} else {
const decimals = guessDecimals(numVal);
result.statusEntries.push('FAIL ' + val + decimals);
}
} else {
// PASS with value
const numVal = parseFloat(rawVal);
if (isNaN(numVal)) {
result.statusEntries.push('PASS');
} else {
const decimals = guessDecimals(numVal);
result.statusEntries.push('PASS ' + rawVal.trim() + decimals);
}
}
}
// Error percentages follow the 31 values - these are the accuracy test point errors
const errorStart = 5 + 31;
for (let i = errorStart; i < parts.length; i++) {
const val = parseFloat((parts[i] || '').trim());
if (!isNaN(val)) {
result.accuracy.push({
stim: 0, // Stimulus not stored in 7B CSV format
calc: 0,
meas: 0,
error: val * 100, // Convert fraction to percentage
status: 'PASS',
});
}
}
return result;
}
/**
* Guess the decimal format digit based on value magnitude
*/
function guessDecimals(val) {
const abs = Math.abs(val);
if (abs === 0) return '0';
if (abs >= 100) return '0';
if (abs >= 10) return '1';
if (abs >= 1) return '1';
if (abs >= 0.1) return '3';
return '4';
}
// -------------------------------------------------------------------------
// SCMVAS / SCMHVAS: Accuracy-only datasheet (no spec lookup)
// -------------------------------------------------------------------------
// QB's STR$() emits SINGLE values in two formats depending on magnitude:
// (1) scientific with a trailing test-status digit: "PASS-7.005501E-033"
// (the trailing single digit is a status code, dropped)
// (2) plain decimal without status digit: "PASS .01599373" or "PASS-.00499773"
// Both are already in percent units (not fractions). Try scientific first,
// then plain-decimal as fallback.
const SCMVAS_ACCURACY_RE_SCI = /^(PASS|FAIL)\s*(-?\d+\.?\d*E[+-]?\d{2})\d?$/i;
const SCMVAS_ACCURACY_RE_PLAIN = /^(PASS|FAIL)\s*(-?\.?\d+\.?\d*)$/i;
function extractSCMVASAccuracy(rawData) {
if (!rawData) return null;
// Scan every quoted string in raw_data for a PASS/FAIL + float value.
// raw_data lines look like: "PASS-7.005501E-033","","","" — so we extract
// each quoted token and test it against the regex.
const tokens = rawData.match(/"[^"]*"/g) || [];
for (const tok of tokens) {
const inner = tok.slice(1, -1).trim();
if (!inner) continue;
const m = inner.match(SCMVAS_ACCURACY_RE_SCI) || inner.match(SCMVAS_ACCURACY_RE_PLAIN);
if (m) {
const passFail = m[1].toUpperCase();
const value = parseFloat(m[2]);
if (isNaN(value)) return null;
return { passFail, value };
}
}
return null;
}
function formatSCMVASAccuracyDisplay(value) {
const abs = Math.abs(value);
let str = abs.toFixed(3);
// Trim trailing zeros after decimal, but preserve at least one digit.
if (str.indexOf('.') >= 0) {
str = str.replace(/0+$/, '').replace(/\.$/, '');
}
return str + '%';
}
function formatSCMVASDate(testDate) {
if (!testDate) return '';
// Accept YYYY-MM-DD (DB), MM-DD-YYYY or MM/DD/YYYY (raw). Normalize to MM/DD/YYYY.
const s = String(testDate).trim();
let m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) return `${m[2]}/${m[3]}/${m[1]}`;
m = s.match(/^(\d{2})[-/](\d{2})[-/](\d{4})$/);
if (m) return `${m[1]}/${m[2]}/${m[3]}`;
return s;
}
function generateSCMVASDatasheet(record) {
const acc = extractSCMVASAccuracy(record.raw_data);
if (!acc) return null;
const TAB8 = ' ';
const modelName = (record.model_number || '').trim();
const sn = (record.serial_number || '').trim();
const dateStr = formatSCMVASDate(record.test_date);
const measured = formatSCMVASAccuracyDisplay(acc.value);
const status = acc.passFail;
const lines = [];
// Header
lines.push(TAB8 + 'Dataforth Corporation Phone number: (520) 741-1404');
lines.push(TAB8 + '3331 E. Hemisphere Loop Fax: (520) 741-0762');
lines.push(TAB8 + 'Tucson, AZ 85706 USA Email: info@dataforth.com');
lines.push('');
lines.push('');
lines.push('');
lines.push('');
lines.push(' TEST DATA SHEET');
lines.push(TAB8 + '~'.repeat(71));
lines.push(TAB8 + 'Date: ' + dateStr);
lines.push(TAB8 + 'Model: ' + modelName);
lines.push(TAB8 + 'SN: ' + sn);
// Section header: centered "FINAL TEST RESULTS" padded to column 77 to match golden samples.
lines.push(' FINAL TEST RESULTS ');
lines.push(TAB8 + '~'.repeat(71));
// Results table: columns at 8, 28, 48, 68
let hdr = TAB8 + 'Parameter';
hdr = setCol(hdr, 28, 'Measured Value');
hdr = setCol(hdr, 48, 'Specification');
hdr = setCol(hdr, 68, 'Status');
lines.push(hdr);
let sep = TAB8 + '================';
sep = setCol(sep, 28, '==============');
sep = setCol(sep, 48, '=============');
sep = setCol(sep, 68, '======');
lines.push(sep);
let row = TAB8 + 'Accuracy';
row = setCol(row, 28, measured);
row = setCol(row, 48, '+/- 0.03%');
row = setCol(row, 68, status);
lines.push(row);
lines.push(TAB8);
lines.push(TAB8 + '_'.repeat(71));
lines.push(' Check List');
lines.push('');
lines.push(setCol(TAB8 + 'Module Appearance: __X__', 48, 'Mounting Screw: __X__'));
lines.push('');
lines.push(setCol(TAB8 + 'Pins Straight: __X__', 48, 'Module Header: __X__'));
lines.push('');
lines.push(TAB8 + 'It is hereby certified that the above product is in conformance with');
lines.push(TAB8 + 'all requirements to the extent specified. This product is not');
lines.push(TAB8 + 'authorized or warranted for use in life support devices and/or systems.');
lines.push('');
lines.push(TAB8 + '* NIST traceable calibration certificates support Measured Value data.');
lines.push(TAB8 + 'Calibration services are available through ANSI/NCSL Z540-1 and');
lines.push(TAB8 + 'ISO Guide 25 Certified Metrology Labs.');
lines.push(TAB8);
lines.push(TAB8);
return lines.join('\r\n');
}
module.exports = {
generateExactDatasheet,
generateSCMVASDatasheet,
extractSCMVASAccuracy,
parseRawData,
parse7BRawData,
DATA_LINES,
};