/** * 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 = {}; } // DSCA33/DSCA45 templates recovered from the Hoffman API (their main spec files were // lost in the wipe). Superset schema: also carries a verbatim 2-line `accHeader`, a // `_srcSerial` validation oracle, and a `validated` gate flag. A model renders ONLY // after its render byte-matches the Hoffman original (validated:true) — until then it // stays null/skipped so the pipeline never overwrites a pristine original. let DSCA3345_TEMPLATES = {}; try { DSCA3345_TEMPLATES = require('../dsca33-45-templates.json'); } catch (e) { DSCA3345_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 parsed = parseFloat(valueStr); if (isNaN(parsed)) return { passFail, valStr: valueStr, value: NaN }; // The DOS QuickBASIC stored/computed these as single-precision floats, so the // value at the half-rounding boundary rounds the way single precision rounds, // not double. Recover the single (Math.fround) before formatting — without it, // double-precision toFixed flips last-digit boundaries (e.g. 9.9995 -> "9.999" // here but "10.000" in the original; 46.85 -> "46.9" vs "46.8"). const value = Math.fround(parsed); 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; } /** * Accuracy row for the Hoffman-mined DSCA33/DSCA45 families, whose original software * used different column conventions than the voltage/temp models (verified against the * Hoffman originals): * - mA-output models store calc (and, for DSCA45, meas) in AMPS -> x1000 to display mA. * DSCA33 stores meas already in the display unit (NOT scaled); DSCA45 scales both. * - DSCA33 (AC-RMS) prints stim/calc/meas UNSIGNED (error signed); stim is the AC input * to 3 decimals. * - DSCA45 (frequency input) prints stim as an UNSIGNED integer Hz; calc/meas/error SIGNED. */ function formatAccuracyLineDSCA3345(point, model, accOut) { const scale = /mA/.test(accOut || '') ? 1000 : 1; const isDSCA45 = /^DSCA45/i.test((model || '').trim()); // values were computed in QB single precision; recover the single before formatting // so last-digit rounding at the .5 boundary matches the original (Math.fround). const num = (val, decimals, signed) => ((signed && val >= 0) ? '+' : '') + Math.fround(val).toFixed(decimals); let stimStr, calcStr, measStr; if (isDSCA45) { stimStr = num(point.stim, 0, false).padStart(8); calcStr = num(point.calc * scale, 3, true).padStart(7); measStr = num(point.meas * scale, 3, true).padStart(7); } else { stimStr = num(point.stim, 3, false).padStart(8); calcStr = num(point.calc * scale, 3, false).padStart(7); measStr = num(point.meas, 3, false).padStart(7); } const errorStr = num(point.error, 3, true).padStart(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: the per-model template is the authoritative Final-Test layout (names + // specs + accuracy label). Source is the staged-original set (dsca-templates) or // the Hoffman-mined set (dsca33-45-templates) for the families whose specs were // lost. No template -> do not guess; skip this cert. const dscaKey = (record.model_number || '').trim(); // Hoffman-mined templates take PRECEDENCE: DSCA33/45 were also captured by the // STAGE 1 staged extractor (sometimes with accOut "?" and no accHeader), and that // stale entry must not shadow the authoritative Hoffman-mined one. const dscaTpl = (family === 'DSCA') ? (DSCA3345_TEMPLATES[dscaKey] || DSCA_TEMPLATES[dscaKey] || null) : null; if (family === 'DSCA' && !dscaTpl) return null; // Hoffman-mined DSCA33/45 render only once the model is byte-validated against its // Hoffman original — otherwise stay null so an unverified render can't overwrite a // live original. The validation harness sets DSCA_VALIDATE_MODE to render // unvalidated models for the byte-compare; the live service never sets it. if (family === 'DSCA' && DSCA3345_TEMPLATES[dscaKey] && !DSCA3345_TEMPLATES[dscaKey].validated && !process.env.DSCA_VALIDATE_MODE) 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(''); if (dscaTpl && Array.isArray(dscaTpl.accHeader) && dscaTpl.accHeader.length >= 2) { // DSCA33/45 (Hoffman-mined): the accuracy header carries model-specific tokens // the sensor-type logic can't synthesize (Vin (mVAC), Iin (AAC), Frequency (Hz), // Output (VDC)/(mADC)). Emit the verbatim 2-line header from the original. lines.push(dscaTpl.accHeader[0]); lines.push(dscaTpl.accHeader[1]); lines.push(TAB5 + '-'.repeat(10) + ' ' + '-'.repeat(11) + ' ' + '-'.repeat(11) + ' ' + '-'.repeat(10) + ' ' + '-'.repeat(8)); } else { 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) { if (dscaTpl && Array.isArray(dscaTpl.accHeader)) { lines.push(formatAccuracyLineDSCA3345(point, record.model_number, dscaTpl.accOut)); continue; } 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 sound only when there is exactly one measured // value per spec-bearing row. When counts differ, this subtype measures slots // the template omits (e.g. an extra load pair); use the per-model slotMap // (absolute statusEntries index per spec-bearing row, derived from the staged // originals) to pull the right value. With no usable slotMap, skip rather than // misalign ("do not guess"). const useSlot = (measurements.length !== specRowCount) && Array.isArray(dscaTpl.slotMap) && dscaTpl.slotMap.length === specRowCount; if (measurements.length !== specRowCount && !useSlot) 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); const is3345 = Array.isArray(dscaTpl.accHeader); // Hoffman-mined DSCA33/45 let mi = 0, si = 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 = useSlot ? formatMeasuredExact(parsed.statusEntries[dscaTpl.slotMap[si++]]) : measurements[mi++]; if (m) { // measured value right-justified ending at col 38, unit at col 40. // DSCA33/45 follow QB's fixed 6-char number field: a value that would // overflow drops its leading zero to fit ("-0.0005" (7) -> "-.0005" (6)); // values that already fit (e.g. "-0.750", "0.0000") keep it. let v = String(m.valStr); if (is3345 && v.length > 6) v = v.replace(/^-0\./, '-.'); 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 if (is3345 && !/withstand|hi-?pot/i.test(row.name)) { // DSCA33/45 spec-less rows that are NOT a pass/fail test (e.g. the // "Zero-Crossing Input" / "TTL Input" section sub-heads) carry no status. // (Leave the row as just the name.) } else { // spec-less pass/fail row (240VAC Withstand / Hi-Pot): blank measured + PASS line = setCol(line, 70, 'PASS'); } lines.push(line); } // Footer load note ("Standard output load for test is ... ohms.") — printed // before the underline, only by the models whose staged original had it // (captured per-model in STAGE 1; not all current-output models print it). if (dscaTpl.loadNote) { lines.push(''); lines.push(TAB5 + dscaTpl.loadNote); } } 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 (/^DSCA33/i.test((record.model_number || '').trim())) { // DSCA33 originals print just the centered "Check List" header (no items). lines.push(' Check List'); } 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__')); } 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'); } /** * True if the model renders from a template that does NOT require spec-reader specs * (the Hoffman-mined DSCA33/45 set). Lets render-datasheet.js skip the missing-specs * bail for these. (Still gated on per-model `validated` inside generateExactDatasheet.) */ function rendersWithoutSpecs(modelNumber) { return !!DSCA3345_TEMPLATES[(modelNumber || '').trim()]; } module.exports = { generateExactDatasheet, generateSCMVASDatasheet, extractSCMVASAccuracy, parseRawData, parse7BRawData, rendersWithoutSpecs, DATA_LINES, };