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>
776 lines
31 KiB
JavaScript
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 };
|