/** * 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 ], 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 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; if (family === 'SCMVAS') { return generateSCMVASDatasheet(record); } 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') { lines.push(TAB5 + '240 VAC Withstand' + ''.padEnd(49) + 'PASS'); lines.push(TAB5 + 'Hi-Pot' + ''.padEnd(60) + 'PASS'); } // Underline + Check List lines.push(TAB5 + '_'.repeat(71)); if (family === 'SCM7B') { lines.push(' Packing Check List'); lines.push(''); lines.push(setCol(TAB5 + 'Module Appearance: _____', 44, 'Mounting Screw: _____')); lines.push(''); lines.push(setCol(TAB5 + 'Pins Straight: _____', 44, 'Module Header: _____')); lines.push(''); lines.push(setCol(TAB5 + 'Tested by: _____________', 44, 'QC: _______________')); } else if (family !== 'DSCA') { lines.push(' Check List'); lines.push(''); lines.push(setCol(TAB5 + 'Module Appearance: __X__', 44, 'Mounting Screw: __X__')); lines.push(''); lines.push(setCol(TAB5 + 'Pins Straight: __X__', 44, 'Module Header: __X__')); } // DSCA current output load note if (family === 'DSCA' && specs && specs.OUTSIGTYPE && specs.OUTSIGTYPE.trim().toUpperCase() === 'CURRENT') { lines.push(TAB5 + 'Standard output load for test is 250 ohms.'); } lines.push(''); lines.push(TAB5 + 'It is hereby certified that the above product is in conformance with'); lines.push(TAB5 + 'all requirements to the extent specified. This product is not'); lines.push(TAB5 + 'authorized or warranted for use in life support devices and/or systems.'); lines.push(''); lines.push(TAB5 + '* NIST traceable calibration certificates support Measured Value data.'); lines.push(TAB5 + ' Calibration services are available through ANSI/NCSL Z540-1 and'); lines.push(TAB5 + ' ISO Guide 25 Certified Metrology Labs.'); lines.push(''); return lines.join('\r\n'); } /** * Parse 7B raw_data (single CSV line format) * Format: STAGE: MODEL,SN,DATE,VERSION,DMMSERIAL,val1,...val31,err1,...errN * val=9999 means not tested, [val] means FAIL */ function parse7BRawData(rawData) { if (!rawData) return null; const match = rawData.match(/^([A-Z-]+):\s*(.*)$/); if (!match) return null; const parts = match[2].split(','); if (parts.length < 36) return null; // model + sn + date + version + dmmserial + 31 values minimum const result = { modelLine: parts[0].trim(), accuracy: [], stepResponse: 0, statusEntries: [], }; // Values start at index 5 (after model, sn, date, version, dmmserial) for (let i = 0; i < 31; i++) { const rawVal = (parts[5 + i] || '').trim(); if (rawVal === '9999' || rawVal === '') { // Not tested - push short "PASS" (will be skipped by formatter) result.statusEntries.push('PASS'); } else if (rawVal.startsWith('[')) { // FAIL - bracketed value const val = rawVal.replace(/[\[\]]/g, '').trim(); const numVal = parseFloat(val); if (isNaN(numVal) || numVal === 0) { result.statusEntries.push('FAIL'); } else { const decimals = guessDecimals(numVal); result.statusEntries.push('FAIL ' + val + decimals); } } else { // PASS with value const numVal = parseFloat(rawVal); if (isNaN(numVal)) { result.statusEntries.push('PASS'); } else { const decimals = guessDecimals(numVal); result.statusEntries.push('PASS ' + rawVal.trim() + decimals); } } } // Error percentages follow the 31 values - these are the accuracy test point errors const errorStart = 5 + 31; for (let i = errorStart; i < parts.length; i++) { const val = parseFloat((parts[i] || '').trim()); if (!isNaN(val)) { result.accuracy.push({ stim: 0, // Stimulus not stored in 7B CSV format calc: 0, meas: 0, error: val * 100, // Convert fraction to percentage status: 'PASS', }); } } return result; } /** * Guess the decimal format digit based on value magnitude */ function guessDecimals(val) { const abs = Math.abs(val); if (abs === 0) return '0'; if (abs >= 100) return '0'; if (abs >= 10) return '1'; if (abs >= 1) return '1'; if (abs >= 0.1) return '3'; return '4'; } // ------------------------------------------------------------------------- // SCMVAS / SCMHVAS: Accuracy-only datasheet (no spec lookup) // ------------------------------------------------------------------------- // QB's STR$() emits SINGLE values in two formats depending on magnitude: // (1) scientific with a trailing test-status digit: "PASS-7.005501E-033" // (the trailing single digit is a status code, dropped) // (2) plain decimal without status digit: "PASS .01599373" or "PASS-.00499773" // Both are already in percent units (not fractions). Try scientific first, // then plain-decimal as fallback. const SCMVAS_ACCURACY_RE_SCI = /^(PASS|FAIL)\s*(-?\d+\.?\d*E[+-]?\d{2})\d?$/i; const SCMVAS_ACCURACY_RE_PLAIN = /^(PASS|FAIL)\s*(-?\.?\d+\.?\d*)$/i; function extractSCMVASAccuracy(rawData) { if (!rawData) return null; // Scan every quoted string in raw_data for a PASS/FAIL + float value. // raw_data lines look like: "PASS-7.005501E-033","","","" — so we extract // each quoted token and test it against the regex. const tokens = rawData.match(/"[^"]*"/g) || []; for (const tok of tokens) { const inner = tok.slice(1, -1).trim(); if (!inner) continue; const m = inner.match(SCMVAS_ACCURACY_RE_SCI) || inner.match(SCMVAS_ACCURACY_RE_PLAIN); if (m) { const passFail = m[1].toUpperCase(); const value = parseFloat(m[2]); if (isNaN(value)) return null; return { passFail, value }; } } return null; } function formatSCMVASAccuracyDisplay(value) { const abs = Math.abs(value); let str = abs.toFixed(3); // Trim trailing zeros after decimal, but preserve at least one digit. if (str.indexOf('.') >= 0) { str = str.replace(/0+$/, '').replace(/\.$/, ''); } return str + '%'; } function formatSCMVASDate(testDate) { if (!testDate) return ''; // Accept YYYY-MM-DD (DB), MM-DD-YYYY or MM/DD/YYYY (raw). Normalize to MM/DD/YYYY. const s = String(testDate).trim(); let m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (m) return `${m[2]}/${m[3]}/${m[1]}`; m = s.match(/^(\d{2})[-/](\d{2})[-/](\d{4})$/); if (m) return `${m[1]}/${m[2]}/${m[3]}`; return s; } function generateSCMVASDatasheet(record) { const acc = extractSCMVASAccuracy(record.raw_data); if (!acc) return null; const TAB8 = ' '; const modelName = (record.model_number || '').trim(); const sn = (record.serial_number || '').trim(); const dateStr = formatSCMVASDate(record.test_date); const measured = formatSCMVASAccuracyDisplay(acc.value); const status = acc.passFail; const lines = []; // Header lines.push(TAB8 + 'Dataforth Corporation Phone number: (520) 741-1404'); lines.push(TAB8 + '3331 E. Hemisphere Loop Fax: (520) 741-0762'); lines.push(TAB8 + 'Tucson, AZ 85706 USA Email: info@dataforth.com'); lines.push(''); lines.push(''); lines.push(''); lines.push(''); lines.push(' TEST DATA SHEET'); lines.push(TAB8 + '~'.repeat(71)); lines.push(TAB8 + 'Date: ' + dateStr); lines.push(TAB8 + 'Model: ' + modelName); lines.push(TAB8 + 'SN: ' + sn); // Section header: centered "FINAL TEST RESULTS" padded to column 77 to match golden samples. lines.push(' FINAL TEST RESULTS '); lines.push(TAB8 + '~'.repeat(71)); // Results table: columns at 8, 28, 48, 68 let hdr = TAB8 + 'Parameter'; hdr = setCol(hdr, 28, 'Measured Value'); hdr = setCol(hdr, 48, 'Specification'); hdr = setCol(hdr, 68, 'Status'); lines.push(hdr); let sep = TAB8 + '================'; sep = setCol(sep, 28, '=============='); sep = setCol(sep, 48, '============='); sep = setCol(sep, 68, '======'); lines.push(sep); let row = TAB8 + 'Accuracy'; row = setCol(row, 28, measured); row = setCol(row, 48, '+/- 0.03%'); row = setCol(row, 68, status); lines.push(row); lines.push(TAB8); lines.push(TAB8 + '_'.repeat(71)); lines.push(' Check List'); lines.push(''); lines.push(setCol(TAB8 + 'Module Appearance: __X__', 48, 'Mounting Screw: __X__')); lines.push(''); lines.push(setCol(TAB8 + 'Pins Straight: __X__', 48, 'Module Header: __X__')); lines.push(''); lines.push(TAB8 + 'It is hereby certified that the above product is in conformance with'); lines.push(TAB8 + 'all requirements to the extent specified. This product is not'); lines.push(TAB8 + 'authorized or warranted for use in life support devices and/or systems.'); lines.push(''); lines.push(TAB8 + '* NIST traceable calibration certificates support Measured Value data.'); lines.push(TAB8 + 'Calibration services are available through ANSI/NCSL Z540-1 and'); lines.push(TAB8 + 'ISO Guide 25 Certified Metrology Labs.'); lines.push(TAB8); lines.push(TAB8); return lines.join('\r\n'); } module.exports = { generateExactDatasheet, generateSCMVASDatasheet, extractSCMVASAccuracy, parseRawData, parse7BRawData, DATA_LINES, };