Add SCMVAS/SCMHVAS datasheet pipeline extension (Dataforth)
Extends the Test Datasheet Pipeline on AD2:C:\Shares\testdatadb to
generate web-published datasheets for the SCMVAS-Mxxx (obsolete) and
SCMHVAS-Mxxxx (replacement) High Voltage Input Module product lines.
Both are tested either with the existing TESTHV3 software (production
VASLOG .DAT logs) or in Engineering with plain .txt output.
Key changes on AD2 (all deployed 2026-04-12 with dated backups):
- parsers/spec-reader.js: getSpecs() returns a `{_family:'SCMVAS',
_noSpecs:true}` sentinel for SCMVAS/SCMHVAS/VAS-M/HVAS-M model prefixes
so the export pipeline does not silently skip them for missing specs.
- templates/datasheet-exact.js: new Accuracy-only template branch
(generateSCMVASDatasheet + helpers) that mirrors the existing shipped
format byte-for-byte. Extraction regex covers both QuickBASIC STR$()
output formats: scientific-with-trailing-status-digit (98.4% of
records) and plain-decimal (1.6% of records above QB's threshold).
- parsers/vaslog-engtxt.js (new): parses the Engineering-Tested .txt
files in TS-3R\LOGS\VASLOG\VASLOG - Engineering Tested\. Filename SN
regex strips optional trailing 14-digit timestamp; in-file "SN:"
header is the authoritative source when the filename is malformed.
- database/import.js: LOG_TYPES grows a VASLOG_ENG entry with
subfolder + recursive flags. Pre-existing 7 log types keep their
implicit recursive=true behaviour (config.recursive !== false).
importFiles() routes VASLOG_ENG paths before the generic loop so a
VASLOG - Engineering Tested/*.txt path does not mis-dispatch to the
multiline parser.
- database/export-datasheets.js: VASLOG_ENG records are written
verbatim via fs.copyFileSync(source_file, For_Web/<SN>.TXT) for true
byte-level pass-through, with a graceful raw_data fallback when the
source file is no longer on disk.
Deploy outcome:
- 27,503 SCMVAS/SCMHVAS datasheets rendered (27,065 from scientific +
438 from plain-decimal PASS lines, post-patch rerun)
- 434 Engineering-Tested .txt files pass-through-copied to For_Web
- 0 errors across both batches
Repo layout added here:
- scmvas-hvas-research/: discovery artifacts (source .BAS, hvin.dat,
sample .DAT + .txt, binary-format notes, IMPLEMENTATION_PLAN.md)
- implementation/: staged final code + deploy helpers + local test
harness + per-step verification scripts
- backups/pre-deploy-20260412/: independent local snapshot of the 4
AD2 files replaced, pulled byte-for-byte before deploy
All helper scripts fetch the AD2 password at runtime from the SOPS
vault (clients/dataforth/ad2.sops.yaml). None of the committed files
contain the plaintext credential. Known vault-entry hygiene issue
(stale shell-escape backslash before the `!`) is documented in the
fetcher comments and stripped at read-time; flagged separately for
cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Parser for single-line CSV format (7BLOG)
|
||||
*
|
||||
* Format:
|
||||
* STAGE: MODEL,SERIAL,DATE,VERSION,CODE,VALUE1,VALUE2,...
|
||||
* Example:
|
||||
* FINAL: 7B21,87876-1,05-08-2013,1.984,0651945, 12, 9999, ...
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Parse a 7BLOG CSV file and extract test records
|
||||
* @param {string} filePath - Path to the DAT file
|
||||
* @param {string} testStation - Test station identifier
|
||||
* @returns {Array} Array of parsed records
|
||||
*/
|
||||
function parseCsvFile(filePath, testStation = null) {
|
||||
const records = [];
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').map(l => l.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
|
||||
// Match pattern: STAGE: MODEL,SERIAL,DATE,...
|
||||
const match = line.match(/^([A-Z-]+):\s*([^,]+),([^,]+),(\d{2}-\d{2}-\d{4}),(.*)$/);
|
||||
|
||||
if (match) {
|
||||
const [, stage, model, serial, dateStr, rest] = match;
|
||||
|
||||
// Parse date from MM-DD-YYYY to YYYY-MM-DD
|
||||
const [month, day, year] = dateStr.split('-');
|
||||
const testDate = `${year}-${month}-${day}`;
|
||||
|
||||
// Model number includes the stage prefix for 7B products
|
||||
const modelNumber = model.trim();
|
||||
|
||||
records.push({
|
||||
log_type: '7BLOG',
|
||||
model_number: modelNumber,
|
||||
serial_number: serial.trim(),
|
||||
test_date: testDate,
|
||||
test_station: testStation,
|
||||
overall_result: 'PASS', // 7BLOG entries are typically passing records
|
||||
raw_data: line,
|
||||
source_file: filePath
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error parsing ${filePath}: ${err.message}`);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract test station from file path
|
||||
*/
|
||||
function extractTestStation(filePath) {
|
||||
const match = filePath.match(/TS-\d+[LR]/i);
|
||||
return match ? match[0].toUpperCase() : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseCsvFile,
|
||||
extractTestStation
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Parser for multi-line DAT files (DSCLOG, 5BLOG, 8BLOG, PWRLOG, SCTLOG, VASLOG)
|
||||
*
|
||||
* Format:
|
||||
* "MODEL_NUMBER "
|
||||
* measurement1,measurement2,measurement3,measurement4,"PASS/FAIL"
|
||||
* ... (test data lines)
|
||||
* 0
|
||||
* "summary line 1"
|
||||
* ...
|
||||
* "SERIAL-NUM","MM-DD-YYYY"
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Parse a multi-line DAT file and extract test records
|
||||
* @param {string} filePath - Path to the DAT file
|
||||
* @param {string} logType - Type of log (DSCLOG, 5BLOG, etc.)
|
||||
* @param {string} testStation - Test station identifier (TS-1L, etc.)
|
||||
* @returns {Array} Array of parsed records
|
||||
*/
|
||||
function parseMultilineFile(filePath, logType, testStation = null) {
|
||||
const records = [];
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').map(l => l.trim());
|
||||
|
||||
let currentRecord = [];
|
||||
let modelNumber = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip empty lines
|
||||
if (!line) continue;
|
||||
|
||||
// Check if it's a serial/date line (format: "SERIAL","DATE")
|
||||
const serialDateMatch = line.match(/^"(\d+-\d+[A-Za-z]?)","(\d{2}-\d{2}-\d{4})"$/);
|
||||
|
||||
if (serialDateMatch) {
|
||||
// This is the end of a record
|
||||
const serialNumber = serialDateMatch[1];
|
||||
const dateStr = serialDateMatch[2];
|
||||
|
||||
if (modelNumber && currentRecord.length > 0) {
|
||||
// Parse date from MM-DD-YYYY to YYYY-MM-DD
|
||||
const [month, day, year] = dateStr.split('-');
|
||||
const testDate = `${year}-${month}-${day}`;
|
||||
|
||||
// Determine overall result from raw data
|
||||
const rawData = currentRecord.join('\n');
|
||||
const overallResult = determineResult(rawData);
|
||||
|
||||
records.push({
|
||||
log_type: logType,
|
||||
model_number: modelNumber.trim(),
|
||||
serial_number: serialNumber,
|
||||
test_date: testDate,
|
||||
test_station: testStation,
|
||||
overall_result: overallResult,
|
||||
raw_data: rawData,
|
||||
source_file: filePath
|
||||
});
|
||||
}
|
||||
|
||||
// Reset for next record
|
||||
currentRecord = [];
|
||||
modelNumber = null;
|
||||
}
|
||||
// Check if this is a model number line
|
||||
// Model numbers: single quoted string with product code (letters+numbers, possibly with dash)
|
||||
// Examples: "DSCA38-1793 ", "SCM5B30-01 ", "8B30-01 "
|
||||
else if (/^"[A-Z0-9]+[A-Z0-9-]*\s*"$/.test(line) && !line.includes(',') && !line.includes('PASS') && !line.includes('FAIL')) {
|
||||
// This is a model number line - start new record
|
||||
if (currentRecord.length > 0 && modelNumber) {
|
||||
// Previous record didn't have serial/date - skip it
|
||||
currentRecord = [];
|
||||
}
|
||||
modelNumber = line.replace(/"/g, '').trim();
|
||||
currentRecord.push(line);
|
||||
} else {
|
||||
// Add line to current record
|
||||
currentRecord.push(line);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error parsing ${filePath}: ${err.message}`);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine overall PASS/FAIL result from raw data
|
||||
*/
|
||||
function determineResult(rawData) {
|
||||
const failCount = (rawData.match(/"FAIL/gi) || []).length;
|
||||
const passCount = (rawData.match(/"PASS/gi) || []).length;
|
||||
|
||||
if (failCount > 0) return 'FAIL';
|
||||
if (passCount > 0) return 'PASS';
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract test station from file path
|
||||
*/
|
||||
function extractTestStation(filePath) {
|
||||
const match = filePath.match(/TS-\d+[LR]/i);
|
||||
return match ? match[0].toUpperCase() : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMultilineFile,
|
||||
extractTestStation
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Parser for SHT (Test Data Sheet) files
|
||||
*
|
||||
* Format:
|
||||
* DATAFORTH CORPORATION ...
|
||||
* ...
|
||||
* TEST DATA SHEET
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Date: MM-DD-YYYY
|
||||
* Model: MODEL_NUMBER
|
||||
* SN: SERIAL-NUM
|
||||
*
|
||||
* Parameter Measured Value Specification Status
|
||||
* ======================= =============== ==================== ======
|
||||
* Supply Current 12.0 mA < 30 mA PASS
|
||||
* ...
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Parse an SHT file and extract the test record
|
||||
* @param {string} filePath - Path to the SHT file
|
||||
* @param {string} testStation - Test station identifier
|
||||
* @returns {Array} Array with single parsed record (or empty if parse fails)
|
||||
*/
|
||||
function parseShtFile(filePath, testStation = null) {
|
||||
const records = [];
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let date = null;
|
||||
let model = null;
|
||||
let serial = null;
|
||||
let hasFailure = false;
|
||||
|
||||
for (const line of lines) {
|
||||
// Extract date
|
||||
const dateMatch = line.match(/^Date:\s*(\d{2}-\d{2}-\d{4})/);
|
||||
if (dateMatch) {
|
||||
const [month, day, year] = dateMatch[1].split('-');
|
||||
date = `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Extract model
|
||||
const modelMatch = line.match(/^Model:\s*(\S+)/);
|
||||
if (modelMatch) {
|
||||
model = modelMatch[1].trim();
|
||||
}
|
||||
|
||||
// Extract serial number
|
||||
const snMatch = line.match(/^SN:\s*(\S+)/);
|
||||
if (snMatch) {
|
||||
serial = snMatch[1].trim();
|
||||
}
|
||||
|
||||
// Check for FAIL status
|
||||
if (/\bFAIL\b/i.test(line)) {
|
||||
hasFailure = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (date && model && serial) {
|
||||
records.push({
|
||||
log_type: 'SHT',
|
||||
model_number: model,
|
||||
serial_number: serial,
|
||||
test_date: date,
|
||||
test_station: testStation,
|
||||
overall_result: hasFailure ? 'FAIL' : 'PASS',
|
||||
raw_data: content,
|
||||
source_file: filePath
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error parsing ${filePath}: ${err.message}`);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract test station from file path
|
||||
*/
|
||||
function extractTestStation(filePath) {
|
||||
const match = filePath.match(/TS-\d+[LR]/i);
|
||||
return match ? match[0].toUpperCase() : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseShtFile,
|
||||
extractTestStation
|
||||
};
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Spec Reader - Parses QuickBASIC binary DAT spec files
|
||||
*
|
||||
* Reads model specification data from 4 product family DAT files:
|
||||
* 5BMAIN.DAT (SCM5B family, 160 bytes/record)
|
||||
* 8BMAIN.DAT (8B family, 163 bytes/record)
|
||||
* DSCOUT.DAT (DSCA family, 163 bytes/record)
|
||||
* SCTMAIN.DAT (DSCT family, 121 bytes/record)
|
||||
*
|
||||
* These are QuickBASIC random-access files using TYPE (struct) records.
|
||||
* All values are little-endian: SINGLE = IEEE 754 float (4 bytes),
|
||||
* INTEGER = signed 16-bit (2 bytes), STRING * N = fixed-width ASCII.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Default spec data directory
|
||||
const DEFAULT_SPEC_DIR = path.join(__dirname, '..', 'specdata');
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Binary read helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function readString(buf, offset, length) {
|
||||
return buf.toString('ascii', offset, offset + length).replace(/\0/g, '').trim();
|
||||
}
|
||||
|
||||
function readSingle(buf, offset) {
|
||||
return buf.readFloatLE(offset);
|
||||
}
|
||||
|
||||
function readInteger(buf, offset) {
|
||||
return buf.readInt16LE(offset);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TYPE definitions (field name, type, size)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const FIELD_TYPES = {
|
||||
STRING17: { size: 17, read: (buf, off) => readString(buf, off, 17) },
|
||||
STRING9: { size: 9, read: (buf, off) => readString(buf, off, 9) },
|
||||
STRING15: { size: 15, read: (buf, off) => readString(buf, off, 15) },
|
||||
STRING14: { size: 14, read: (buf, off) => readString(buf, off, 14) },
|
||||
STRING13: { size: 13, read: (buf, off) => readString(buf, off, 13) },
|
||||
STRING7: { size: 7, read: (buf, off) => readString(buf, off, 7) },
|
||||
SINGLE: { size: 4, read: (buf, off) => readSingle(buf, off) },
|
||||
INTEGER: { size: 2, read: (buf, off) => readInteger(buf, off) },
|
||||
};
|
||||
|
||||
const S15 = 'STRING15';
|
||||
const S14 = 'STRING14';
|
||||
const S13 = 'STRING13';
|
||||
const S7 = 'STRING7';
|
||||
const SNG = 'SINGLE';
|
||||
const INT = 'INTEGER';
|
||||
|
||||
// SCM5B: 160 bytes/record
|
||||
const SCM5B_FIELDS = [
|
||||
['MODNAME', S15], ['SENTYPE', S7],
|
||||
['ISMAXNEXCL', SNG], ['ISMAXFEXCL', SNG], ['OUTRES', SNG],
|
||||
['MININ', SNG], ['MAXIN', SNG], ['IEXC', SNG],
|
||||
['RCONV', SNG], ['MINOUT', SNG], ['MAXOUT', SNG],
|
||||
['OSCALPT', SNG], ['GNCALPT', SNG], ['CALTOL', SNG],
|
||||
['VEXC', SNG], ['VEXCACC', SNG], ['EXCLOAD', SNG],
|
||||
['EXCLOADREG', SNG], ['EXCIMAX', SNG], ['LINEAR', SNG],
|
||||
['ACCURACY', SNG], ['TESTFREQ', SNG], ['ATTEN', SNG],
|
||||
['ATTENTOL', SNG], ['STEPRMIN', SNG], ['STEPRMAX', SNG],
|
||||
['PSS', SNG], ['OUTNOISE', SNG], ['INPUTRES', SNG],
|
||||
['VOPENINMIN', SNG], ['VOPENINMAX', SNG], ['LEADRERR', SNG],
|
||||
['LINEARIZED', INT], ['OSCALIN', SNG], ['GNCALIN', SNG],
|
||||
['BANDWIDTH', SNG], ['IMATCHTOL', SNG],
|
||||
];
|
||||
|
||||
// 8B: 163 bytes/record (no OUTRES, has OUTSIGTYPE)
|
||||
const B8_FIELDS = [
|
||||
['MODNAME', S15], ['SENTYPE', S7],
|
||||
['ISMAXNEXCL', SNG], ['ISMAXFEXCL', SNG],
|
||||
['MININ', SNG], ['MAXIN', SNG], ['IEXC', SNG],
|
||||
['RCONV', SNG], ['OUTSIGTYPE', S7],
|
||||
['MINOUT', SNG], ['MAXOUT', SNG],
|
||||
['OSCALPT', SNG], ['GNCALPT', SNG], ['CALTOL', SNG],
|
||||
['VEXC', SNG], ['VEXCACC', SNG], ['EXCLOAD', SNG],
|
||||
['EXCLOADREG', SNG], ['EXCIMAX', SNG], ['LINEAR', SNG],
|
||||
['ACCURACY', SNG], ['TESTFREQ', SNG], ['ATTEN', SNG],
|
||||
['ATTENTOL', SNG], ['STEPRMIN', SNG], ['STEPRMAX', SNG],
|
||||
['PSS', SNG], ['OUTNOISE', SNG], ['INPUTRES', SNG],
|
||||
['VOPENINMIN', SNG], ['VOPENINMAX', SNG], ['LEADRERR', SNG],
|
||||
['LINEARIZED', INT], ['OSCALIN', SNG], ['GNCALIN', SNG],
|
||||
['BANDWIDTH', SNG], ['IMATCHTOL', SNG],
|
||||
];
|
||||
|
||||
// DSCA: 163 bytes/record
|
||||
const DSCA_FIELDS = [
|
||||
['MODNAME', S13], ['SENTYPE', S7],
|
||||
['ISMAXNL', SNG], ['ISMAXFL', SNG],
|
||||
['MININ', SNG], ['MAXIN', SNG], ['RCONV', SNG],
|
||||
['MINOUT', SNG], ['MAXOUT', SNG], ['OUTSIGTYPE', S7],
|
||||
['OSCALIN', SNG], ['GNCALIN', SNG], ['CALTOL', SNG],
|
||||
['LOAD1', SNG], ['LINEAR1', SNG], ['ACCURACY1', SNG],
|
||||
['LOAD2', SNG], ['LINEAR2', SNG], ['ACCURACY2', SNG],
|
||||
['LOAD3', SNG], ['LINEAR3', SNG], ['ACCURACY3', SNG],
|
||||
['BANDWIDTH', SNG], ['TESTFREQ', SNG], ['ATTEN', SNG],
|
||||
['ATTENTOL', SNG], ['STEPRMIN', SNG], ['STEPRMAX', SNG],
|
||||
['PSS', SNG], ['OUTNOISE', SNG], ['INPUTRES', SNG],
|
||||
['COMPLIANCE', SNG], ['MAXLOAD', SNG], ['ILIMIT', SNG],
|
||||
['PERCOVER', SNG], ['MINVS', SNG], ['MAXVS', SNG],
|
||||
];
|
||||
|
||||
// DSCT: 121 bytes/record (uses INTEGER for some fields)
|
||||
const DSCT_FIELDS = [
|
||||
['MODNAME', S14], ['SENTYPE', S7],
|
||||
['MININ', SNG], ['MAXIN', SNG],
|
||||
['IEXCMFS', SNG], ['IEXCPFS', SNG],
|
||||
['RCONV', SNG], ['MINOUT', SNG], ['MAXOUT', SNG],
|
||||
['OSCALPT', SNG], ['GNCALPT', SNG],
|
||||
['LINEAR', SNG], ['ACCURACY', SNG],
|
||||
['TESTFREQ', INT], ['ATTEN', INT], ['ATTENTOL', INT],
|
||||
['STEPRMIN', SNG], ['STEPRMAX', SNG],
|
||||
['OUTNOISE', SNG], ['INPUTRES', SNG],
|
||||
['IOPENTC', SNG], ['LEADRERR', SNG],
|
||||
['LINEARIZED', INT], ['OSCALIN', SNG], ['GNCALIN', SNG],
|
||||
['BANDWIDTH', SNG], ['IMATCHTOL', SNG],
|
||||
['CALTOL', SNG], ['VSEN', SNG],
|
||||
];
|
||||
|
||||
const S9 = 'STRING9';
|
||||
|
||||
// SCM5B45: 119 bytes/record (frequency/counter modules)
|
||||
const SCM5B45_FIELDS = [
|
||||
['MODNAME', S9],
|
||||
['MININ', SNG], ['MAXIN', SNG], ['MINOUT', SNG], ['MAXOUT', SNG],
|
||||
['ZHYSAMPL', SNG], ['ZHYSLIM', SNG], ['TTLHYSAMPL', SNG],
|
||||
['TTLLIMHI', SNG], ['TTLLIMLO', SNG], ['MINPW', SNG],
|
||||
['OSCALIN', SNG], ['GNCALIN', SNG], ['CALTOL', SNG],
|
||||
['LINEAR', SNG], ['ACCURACY', SNG],
|
||||
['TESTFREQ', INT], ['ATTEN', INT], ['ATTENTOL', INT],
|
||||
['STEPRMIN', SNG], ['STEPRMAX', SNG],
|
||||
['ISMAX', SNG], ['PSS', SNG],
|
||||
['NOISEMFS', SNG], ['NOISETESTPT', SNG], ['NOISEPFS', SNG],
|
||||
['OUTRES', SNG], ['EXCVOLT', SNG],
|
||||
['EXCTOLNL', SNG], ['EXCTOLL', SNG],
|
||||
];
|
||||
|
||||
// SCM5B48: 264 bytes/record (multi-bandwidth modules)
|
||||
const SCM5B48_FIELDS = [
|
||||
['MODNAME', S15], ['SENTYPE', S7],
|
||||
['ISMAXNEXCL', SNG], ['ISMAXFEXCL', SNG], ['OUTRES', SNG],
|
||||
['MININ', SNG], ['MAXIN', SNG],
|
||||
['MININ1', SNG], ['MAXIN1', SNG],
|
||||
['MININ2', SNG], ['MAXIN2', SNG],
|
||||
['MININ3', SNG], ['MAXIN3', SNG],
|
||||
['IEXC', SNG], ['IEXC1', SNG], ['IEXC2', SNG],
|
||||
['RCONV', SNG], ['MINOUT', SNG], ['MAXOUT', SNG],
|
||||
['OSCALPT', SNG], ['GNCALPT', SNG], ['CALTOL', SNG],
|
||||
['VEXC', SNG], ['VEXCACC', SNG], ['EXCLOAD', SNG],
|
||||
['EXCLOADREG', SNG], ['EXCIMAX', SNG],
|
||||
['LINEAR', SNG], ['ACCURACY', SNG],
|
||||
['TESTFREQ', SNG], ['TESTFREQ1', SNG], ['TESTFREQ2', SNG], ['TESTFREQ3', SNG], ['TESTFREQ4', SNG],
|
||||
['ATTEN', SNG], ['ATTEN1', SNG], ['ATTEN2', SNG], ['ATTEN3', SNG], ['ATTEN4', SNG],
|
||||
['ATTENTOL', SNG],
|
||||
['STEPRMIN', SNG], ['STEPRMAX', SNG],
|
||||
['PSS', SNG], ['PSS1', SNG], ['PSS2', SNG], ['PSS3', SNG],
|
||||
['OUTNOISE', SNG], ['OUTNOISE1', SNG], ['OUTNOISE2', SNG], ['OUTNOISE3', SNG],
|
||||
['INPUTRES', SNG], ['VOPENINMIN', SNG], ['VOPENINMAX', SNG], ['LEADRERR', SNG],
|
||||
['LINEARIZED', INT],
|
||||
['OSCALIN', SNG], ['GNCALIN', SNG],
|
||||
['BANDWIDTH', SNG], ['BANDWIDTH1', SNG], ['BANDWIDTH2', SNG], ['BANDWIDTH3', SNG], ['BANDWIDTH4', SNG],
|
||||
['IMATCHTOL', SNG],
|
||||
];
|
||||
|
||||
// SCM5B49: 93 bytes/record (sample & hold modules)
|
||||
const SCM5B49_FIELDS = [
|
||||
['MODNAME', S9],
|
||||
['MININ', SNG], ['MAXIN', SNG], ['MINOUT', SNG], ['MAXOUT', SNG],
|
||||
['MAXSUPPLYNL', SNG], ['MAXSUPPLYFL', SNG], ['LIMITOUT', SNG], ['POWERSEN', SNG],
|
||||
['TESTFREQ', INT], ['ATTEN', INT],
|
||||
['LINEAR0MA', SNG], ['LINEAR50MA', SNG],
|
||||
['ACCURACY0MA', SNG], ['ACCURACY50MA', SNG],
|
||||
['STEPRMIN', SNG], ['STEPRMAX', SNG],
|
||||
['NOISEOUT', SNG], ['QINJECT', SNG],
|
||||
['INPUTRES', SNG], ['ACQLIM', SNG],
|
||||
['DROOP', SNG], ['PERCOVER', SNG],
|
||||
];
|
||||
|
||||
// DSCA (TSTDIN1B variant, for DSCMAIN4.DAT): 159 bytes/record
|
||||
const DSCA_DIN_FIELDS = [
|
||||
['MODNAME', S13], ['SENTYPE', S7],
|
||||
['ISMAXNEXCL', SNG], ['ISMAXFEXCL', SNG],
|
||||
['MININ', SNG], ['MAXIN', SNG],
|
||||
['IEXCPFS', SNG], ['IEXCMFS', SNG],
|
||||
['RCONV', SNG], ['OUTSIGTYPE', S7],
|
||||
['MINOUT', SNG], ['MAXOUT', SNG],
|
||||
['OSCALPT', SNG], ['GNCALPT', SNG], ['CALTOL', SNG],
|
||||
['VEXC', SNG], ['VEXCACC', SNG], ['EXCLOAD', SNG],
|
||||
['EXCLOADREG', SNG], ['EXCIMAX', SNG],
|
||||
['LINEAR', SNG], ['ACCURACY', SNG],
|
||||
['TESTFREQ', INT], ['ATTEN', INT], ['ATTENTOL', INT],
|
||||
['STEPRMIN', SNG], ['STEPRMAX', SNG],
|
||||
['PSS', SNG], ['OUTNOISE', SNG], ['INPUTRES', SNG],
|
||||
['OPENTC', SNG], ['LEADRERR', SNG],
|
||||
['LINEARIZED', INT], ['OSCALIN', SNG], ['GNCALIN', SNG],
|
||||
['BANDWIDTH', SNG], ['MINVS', SNG], ['MAXVS', SNG],
|
||||
];
|
||||
|
||||
// SCM7B: 170 bytes/record
|
||||
const S17 = 'STRING17';
|
||||
const SCM7B_FIELDS = [
|
||||
['MODNAME', S17], ['SENTYPE', S7],
|
||||
['MINVS', SNG], ['NOMVS', SNG], ['MAXVS', SNG],
|
||||
['VLIM', SNG], ['ILIM', SNG], ['PE', SNG],
|
||||
['ISMAXNEXCL', SNG],
|
||||
['MININ', SNG], ['MAXIN', SNG],
|
||||
['MINOUT', SNG], ['MAXOUT', SNG],
|
||||
['IEXC', SNG], ['EXCIMIN', SNG], ['EXCIMAX', SNG],
|
||||
['LEADRERR', SNG], ['RCONV', SNG],
|
||||
['OSCALPT', SNG], ['GNCALPT', SNG], ['CALTOL', SNG],
|
||||
['ISMAXFEXCL', SNG],
|
||||
['VEXC', SNG], ['VEXCLO', SNG], ['VEXCHI', SNG],
|
||||
['LOOPIMAX', SNG],
|
||||
['LINEAR', SNG], ['ACCURACY', SNG], ['PSS', SNG],
|
||||
['TESTFREQ', INT], ['ATTEN', INT], ['ATTENTOL', INT],
|
||||
['STEPRESP', SNG], ['STEPTOL', SNG],
|
||||
['OUTNOISERMS', SNG], ['OUTNOISEVPK', SNG],
|
||||
['INPUTRES', SNG], ['VOPENTC', SNG],
|
||||
['CJCACC', SNG], ['IBIAS', SNG],
|
||||
];
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Record size calculation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function calcRecordSize(fields) {
|
||||
let size = 0;
|
||||
for (const [, type] of fields) {
|
||||
size += FIELD_TYPES[type].size;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Parse a single record from a buffer
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function parseRecord(buf, offset, fields) {
|
||||
const record = {};
|
||||
let pos = offset;
|
||||
for (const [name, type] of fields) {
|
||||
const ft = FIELD_TYPES[type];
|
||||
record[name] = ft.read(buf, pos);
|
||||
pos += ft.size;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Parse an entire DAT file into an array of records
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function parseDatFile(filePath, fields) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Spec file not found: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const buf = fs.readFileSync(filePath);
|
||||
const recordSize = calcRecordSize(fields);
|
||||
const numRecords = Math.floor(buf.length / recordSize);
|
||||
const records = [];
|
||||
|
||||
for (let i = 0; i < numRecords; i++) {
|
||||
const offset = i * recordSize;
|
||||
if (offset + recordSize > buf.length) break;
|
||||
|
||||
const record = parseRecord(buf, offset, fields);
|
||||
|
||||
// Skip records with empty, placeholder, or corrupted model names
|
||||
const modname = record.MODNAME;
|
||||
if (!modname || modname.length === 0) continue;
|
||||
// Skip if model name contains non-alphanumeric characters (except dash)
|
||||
if (!/^[A-Za-z0-9-]+$/.test(modname)) continue;
|
||||
// Skip placeholder entries
|
||||
if (/^[XYZ]+$/.test(modname) || /^ZZZZ/.test(modname)) continue;
|
||||
// Skip if MODNAME doesn't start with a known product prefix
|
||||
const upper = modname.toUpperCase();
|
||||
if (!upper.match(/^(SCM5B|5B|SCM7B|7B|8B|DSCA|DSCT|SCT|BOGUS)/)) continue;
|
||||
|
||||
records.push(record);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Family configuration
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const FAMILIES = {
|
||||
SCM5B: {
|
||||
file: '5BMAIN.DAT',
|
||||
fields: SCM5B_FIELDS,
|
||||
family: 'SCM5B',
|
||||
logType: '5BLOG',
|
||||
},
|
||||
B8: {
|
||||
file: '8BMAIN.DAT',
|
||||
fields: B8_FIELDS,
|
||||
family: '8B',
|
||||
logType: '8BLOG',
|
||||
},
|
||||
DSCA: {
|
||||
file: 'DSCOUT.DAT',
|
||||
fields: DSCA_FIELDS,
|
||||
family: 'DSCA',
|
||||
logType: 'DSCLOG',
|
||||
},
|
||||
DSCT: {
|
||||
file: 'SCTMAIN.DAT',
|
||||
fields: DSCT_FIELDS,
|
||||
family: 'DSCT',
|
||||
logType: 'SCTLOG',
|
||||
},
|
||||
DSCA_DIN: {
|
||||
file: 'DSCMAIN4.DAT',
|
||||
fields: DSCA_DIN_FIELDS,
|
||||
family: 'DSCA',
|
||||
logType: 'DSCLOG',
|
||||
},
|
||||
SCM5B45: {
|
||||
file: '5B45DATA.DAT',
|
||||
fields: SCM5B45_FIELDS,
|
||||
family: 'SCM5B',
|
||||
logType: '5BLOG',
|
||||
},
|
||||
SCM5B48: {
|
||||
file: 'DB5B48.DAT',
|
||||
fields: SCM5B48_FIELDS,
|
||||
family: 'SCM5B',
|
||||
logType: '5BLOG',
|
||||
},
|
||||
SCM5B49: {
|
||||
file: '5B49_2.DAT',
|
||||
fields: SCM5B49_FIELDS,
|
||||
family: 'SCM5B',
|
||||
logType: '5BLOG',
|
||||
},
|
||||
SCM7B: {
|
||||
file: '7BMAIN.DAT',
|
||||
fields: SCM7B_FIELDS,
|
||||
family: 'SCM7B',
|
||||
logType: '7BLOG',
|
||||
},
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Main API: load all specs into a lookup map
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load all model specs from binary DAT files.
|
||||
* @param {string} specDir - Directory containing the DAT files
|
||||
* @returns {Map<string, object>} Map of model_number -> spec record (with _family added)
|
||||
*/
|
||||
function loadAllSpecs(specDir) {
|
||||
specDir = specDir || DEFAULT_SPEC_DIR;
|
||||
const specMap = new Map();
|
||||
|
||||
for (const [familyKey, config] of Object.entries(FAMILIES)) {
|
||||
const filePath = path.join(specDir, config.file);
|
||||
const records = parseDatFile(filePath, config.fields);
|
||||
|
||||
for (const record of records) {
|
||||
record._family = config.family;
|
||||
record._logType = config.logType;
|
||||
// Normalize model name for lookup (trim, uppercase)
|
||||
const key = record.MODNAME.toUpperCase().trim();
|
||||
specMap.set(key, record);
|
||||
}
|
||||
|
||||
console.log(`[SPEC] Loaded ${records.length} models from ${config.file} (${config.family})`);
|
||||
}
|
||||
|
||||
console.log(`[SPEC] Total models loaded: ${specMap.size}`);
|
||||
return specMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up specs for a model number.
|
||||
* Tries exact match, then common prefix variations (SCM5B <-> 5B, DSCA <-> DSC).
|
||||
* @param {Map} specMap - Spec map from loadAllSpecs()
|
||||
* @param {string} modelNumber - Model number to look up
|
||||
* @returns {object|null} Spec record or null
|
||||
*/
|
||||
function getSpecs(specMap, modelNumber) {
|
||||
if (!modelNumber) return null;
|
||||
const key = modelNumber.toUpperCase().trim();
|
||||
|
||||
// Exact match
|
||||
if (specMap.has(key)) return specMap.get(key);
|
||||
|
||||
// Try adding/removing SCM prefix: "5B41-03" <-> "SCM5B41-03"
|
||||
if (key.startsWith('SCM5B')) {
|
||||
const short = key.replace('SCM5B', '5B');
|
||||
if (specMap.has(short)) return specMap.get(short);
|
||||
} else if (key.startsWith('5B')) {
|
||||
const full = 'SCM' + key;
|
||||
if (specMap.has(full)) return specMap.get(full);
|
||||
}
|
||||
|
||||
// Try adding/removing SCM prefix for 7B
|
||||
if (key.startsWith('SCM7B')) {
|
||||
const short = key.replace('SCM7B', '7B');
|
||||
if (specMap.has(short)) return specMap.get(short);
|
||||
} else if (key.startsWith('7B')) {
|
||||
const full = 'SCM' + key;
|
||||
if (specMap.has(full)) return specMap.get(full);
|
||||
}
|
||||
|
||||
// Try DSCA variations
|
||||
if (key.startsWith('DSCA')) {
|
||||
// Some specs stored without the 'A'
|
||||
const short = key.replace('DSCA', 'DSC');
|
||||
if (specMap.has(short)) return specMap.get(short);
|
||||
}
|
||||
|
||||
// Try partial match on model base (before any suffix like C, D)
|
||||
// e.g., "DSCA30-05C" -> try "DSCA30-05"
|
||||
const baseMatch = key.match(/^(.+?)([A-Z])$/);
|
||||
if (baseMatch) {
|
||||
const base = baseMatch[1];
|
||||
if (specMap.has(base)) return specMap.get(base);
|
||||
// Also try with prefix variations
|
||||
if (base.startsWith('SCM5B')) {
|
||||
const short = base.replace('SCM5B', '5B');
|
||||
if (specMap.has(short)) return specMap.get(short);
|
||||
} else if (base.startsWith('5B')) {
|
||||
if (specMap.has('SCM' + base)) return specMap.get('SCM' + base);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine product family from model number string
|
||||
*/
|
||||
function getFamily(modelNumber) {
|
||||
if (!modelNumber) return null;
|
||||
const m = modelNumber.toUpperCase();
|
||||
if (m.startsWith('SCM5B') || m.startsWith('5B')) return 'SCM5B';
|
||||
if (m.startsWith('SCM7B') || m.startsWith('7B')) return 'SCM7B';
|
||||
if (m.startsWith('8B')) return '8B';
|
||||
if (m.startsWith('DSCA')) return 'DSCA';
|
||||
if (m.startsWith('DSCT') || m.startsWith('SCT')) return 'DSCT';
|
||||
return null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CLI: test the parser
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
if (require.main === module) {
|
||||
const specDir = process.argv[2] || DEFAULT_SPEC_DIR;
|
||||
console.log(`Loading specs from: ${specDir}\n`);
|
||||
|
||||
const specMap = loadAllSpecs(specDir);
|
||||
|
||||
// Print a few examples from each family
|
||||
const examples = {};
|
||||
for (const [key, spec] of specMap) {
|
||||
const fam = spec._family;
|
||||
if (!examples[fam]) examples[fam] = [];
|
||||
if (examples[fam].length < 3) {
|
||||
examples[fam].push(spec);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fam, specs] of Object.entries(examples)) {
|
||||
console.log(`\n--- ${fam} Examples ---`);
|
||||
for (const s of specs) {
|
||||
console.log(` ${s.MODNAME}: SENTYPE=${s.SENTYPE}, MININ=${s.MININ}, MAXIN=${s.MAXIN}, MINOUT=${s.MINOUT}, MAXOUT=${s.MAXOUT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { loadAllSpecs, getSpecs, getFamily, FAMILIES };
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Work Order Report Parser
|
||||
*
|
||||
* Parses the TXT work order status reports from TS-XX/Reports/ folders.
|
||||
*
|
||||
* Format:
|
||||
* ===================================================================
|
||||
* WO#: 179257
|
||||
* Date: 03-27-2026
|
||||
* Work order status file for work order #: 179257
|
||||
* Program: TEST8B1D.EXE
|
||||
* Version: B.19 2023.08.02 JL
|
||||
* Lib. Ver.: B.09 2019.02.08 MR
|
||||
* -------------------------------------------------------------------
|
||||
* Status Serial# DS File Name Model Date Time
|
||||
* -------- --------- ------------ ------------- ---------- --------
|
||||
* PASS 179257-1 H9257-1.TXT 8B47K-05 03-27-2026 10:25:56
|
||||
* FAIL<<<< 179257-12 8B47K-05 03-27-2026 11:01:09
|
||||
* ...
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Extract test station from file path (e.g., C:\Shares\test\TS-4L\Reports\179257.TXT -> TS-4L)
|
||||
*/
|
||||
function extractStation(filePath) {
|
||||
const match = filePath.match(/[\\\/](TS-[^\\\/]+)[\\\/]/i);
|
||||
return match ? match[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract work order number from filename (e.g., 179257.TXT -> 179257)
|
||||
*/
|
||||
function extractWoFromFilename(filePath) {
|
||||
const base = path.basename(filePath, path.extname(filePath));
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a work order report TXT file
|
||||
* @param {string} filePath - Path to the report file
|
||||
* @returns {object} Parsed work order with header and lines
|
||||
*/
|
||||
function parseWoReport(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
const result = {
|
||||
wo_number: null,
|
||||
wo_date: null,
|
||||
program: null,
|
||||
version: null,
|
||||
lib_version: null,
|
||||
station: extractStation(filePath),
|
||||
source_file: filePath,
|
||||
lines: [], // test result lines
|
||||
ds_files: [], // datasheet files listed at bottom
|
||||
};
|
||||
|
||||
let inHeader = true;
|
||||
let inDsList = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
|
||||
// Parse header fields
|
||||
const woMatch = t.match(/^WO#:\s*(\S+)/);
|
||||
if (woMatch) {
|
||||
result.wo_number = woMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
const dateMatch = t.match(/^Date:\s*(\d{2}-\d{2}-\d{4})/);
|
||||
if (dateMatch) {
|
||||
const [month, day, year] = dateMatch[1].split('-');
|
||||
result.wo_date = `${year}-${month}-${day}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const progMatch = t.match(/^Program:\s*(\S+)/);
|
||||
if (progMatch) {
|
||||
result.program = progMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
const verMatch = t.match(/^Version:\s*(.+)/);
|
||||
if (verMatch) {
|
||||
result.version = verMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
const libMatch = t.match(/^Lib\. Ver\.:\s*(.+)/);
|
||||
if (libMatch) {
|
||||
result.lib_version = libMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect separator lines
|
||||
if (t.match(/^-{20,}$/)) {
|
||||
inHeader = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip header row
|
||||
if (t.startsWith('Status') && t.includes('Serial#')) continue;
|
||||
|
||||
// Detect datasheet file list section
|
||||
if (t.includes('datasheet files actually created')) {
|
||||
inDsList = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDsList) {
|
||||
if (t.match(/^-+$/)) continue;
|
||||
if (t.match(/^\S+\.TXT$/i)) {
|
||||
result.ds_files.push(t);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse test result lines
|
||||
// PASS 179257-1 H9257-1.TXT 8B47K-05 03-27-2026 10:25:56
|
||||
// FAIL<<<< 179257-12 8B47K-05 03-27-2026 11:01:09
|
||||
if (!inHeader && t.length > 0) {
|
||||
const passMatch = t.match(/^(PASS)\s+(\S+)\s+(\S+\.TXT)\s+(\S+)\s+(\d{2}-\d{2}-\d{4})\s+(\d{2}:\d{2}:\d{2})/i);
|
||||
const failMatch = t.match(/^(FAIL[<]*)\s+(\S+)\s+(\S+)\s+(\d{2}-\d{2}-\d{4})\s+(\d{2}:\d{2}:\d{2})/i);
|
||||
|
||||
if (passMatch) {
|
||||
const [, status, serial, dsFile, model, date, time] = passMatch;
|
||||
const [month, day, year] = date.split('-');
|
||||
result.lines.push({
|
||||
status: 'PASS',
|
||||
serial_number: serial,
|
||||
ds_filename: dsFile,
|
||||
model_number: model,
|
||||
test_date: `${year}-${month}-${day}`,
|
||||
test_time: time,
|
||||
});
|
||||
} else if (failMatch) {
|
||||
const [, status, serial, model, date, time] = failMatch;
|
||||
const [month, day, year] = date.split('-');
|
||||
result.lines.push({
|
||||
status: 'FAIL',
|
||||
serial_number: serial,
|
||||
ds_filename: null,
|
||||
model_number: model,
|
||||
test_date: `${year}-${month}-${day}`,
|
||||
test_time: time,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to filename for WO# if not found in content
|
||||
if (!result.wo_number) {
|
||||
result.wo_number = extractWoFromFilename(filePath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { parseWoReport, extractStation, extractWoFromFilename };
|
||||
Reference in New Issue
Block a user