Files
claudetools/projects/dataforth-dos/datasheet-pipeline/scmvas-hvas-research/existing-templates/datasheet-exact.js
Mike Swanson 45083f4735 Add SCMVAS/SCMHVAS datasheet pipeline extension (Dataforth)
Extends the Test Datasheet Pipeline on AD2:C:\Shares\testdatadb to
generate web-published datasheets for the SCMVAS-Mxxx (obsolete) and
SCMHVAS-Mxxxx (replacement) High Voltage Input Module product lines.
Both are tested either with the existing TESTHV3 software (production
VASLOG .DAT logs) or in Engineering with plain .txt output.

Key changes on AD2 (all deployed 2026-04-12 with dated backups):

- parsers/spec-reader.js: getSpecs() returns a `{_family:'SCMVAS',
  _noSpecs:true}` sentinel for SCMVAS/SCMHVAS/VAS-M/HVAS-M model prefixes
  so the export pipeline does not silently skip them for missing specs.
- templates/datasheet-exact.js: new Accuracy-only template branch
  (generateSCMVASDatasheet + helpers) that mirrors the existing shipped
  format byte-for-byte. Extraction regex covers both QuickBASIC STR$()
  output formats: scientific-with-trailing-status-digit (98.4% of
  records) and plain-decimal (1.6% of records above QB's threshold).
- parsers/vaslog-engtxt.js (new): parses the Engineering-Tested .txt
  files in TS-3R\LOGS\VASLOG\VASLOG - Engineering Tested\. Filename SN
  regex strips optional trailing 14-digit timestamp; in-file "SN:"
  header is the authoritative source when the filename is malformed.
- database/import.js: LOG_TYPES grows a VASLOG_ENG entry with
  subfolder + recursive flags. Pre-existing 7 log types keep their
  implicit recursive=true behaviour (config.recursive !== false).
  importFiles() routes VASLOG_ENG paths before the generic loop so a
  VASLOG - Engineering Tested/*.txt path does not mis-dispatch to the
  multiline parser.
- database/export-datasheets.js: VASLOG_ENG records are written
  verbatim via fs.copyFileSync(source_file, For_Web/<SN>.TXT) for true
  byte-level pass-through, with a graceful raw_data fallback when the
  source file is no longer on disk.

Deploy outcome:
- 27,503 SCMVAS/SCMHVAS datasheets rendered (27,065 from scientific +
  438 from plain-decimal PASS lines, post-patch rerun)
- 434 Engineering-Tested .txt files pass-through-copied to For_Web
- 0 errors across both batches

Repo layout added here:
- scmvas-hvas-research/: discovery artifacts (source .BAS, hvin.dat,
  sample .DAT + .txt, binary-format notes, IMPLEMENTATION_PLAN.md)
- implementation/: staged final code + deploy helpers + local test
  harness + per-step verification scripts
- backups/pre-deploy-20260412/: independent local snapshot of the 4
  AD2 files replaced, pulled byte-for-byte before deploy

All helper scripts fetch the AD2 password at runtime from the SOPS
vault (clients/dataforth/ad2.sops.yaml). None of the committed files
contain the plaintext credential. Known vault-entry hygiene issue
(stale shell-escape backslash before the `!`) is documented in the
fetcher comments and stripped at read-time; flagged separately for
cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:36:45 -07:00

776 lines
31 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');
// -------------------------------------------------------------------------
// 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
],
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
if (lineIdx < lines.length) {
const parts = parseCSVLine(lines[lineIdx++]);
// SCM5B/8B: "0","0",value DSCT: just value
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 };
}
// -------------------------------------------------------------------------
// 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) {
// Temperature: +####.##
stimStr = formatSigned(point.stim, 2, 8);
} else if (sensorNum === 7) {
// Resistance: #####.##
stimStr = point.stim.toFixed(2).padStart(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;
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) {
inputHeader = ' Temp. (C)';
} else if (sensorNum === 2 || sensorNum === 9) {
inputHeader = ' Iin (mA)';
} else if (sensorNum === 7) {
inputHeader = ' Rin (ohms)';
} else {
inputHeader = (maxIn != null && maxIn < 1) ? ' Vin (mV)' : ' Vin (V)';
}
lines.push(' ' + inputHeader + ' Vout (V) Vout (V)* Error (%) Status');
lines.push(TAB5 + '========== ========== ========== ========= ========');
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('');
// 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);
}
// ---- 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 === 'DSCA') {
lines.push(TAB5 + '240VAC Withstand' + ''.padEnd(50) + 'PASS');
lines.push(TAB5 + 'Hi-Pot' + ''.padEnd(60) + 'PASS');
} else if (family === 'DSCT') {
const mn = (modelName || '').toUpperCase();
if (!mn.startsWith('SCMHVAS')) {
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';
}
module.exports = { generateExactDatasheet, parseRawData, parse7BRawData, DATA_LINES };