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:
2026-04-12 17:02:20 -07:00
parent 499fd5d01a
commit 45083f4735
114 changed files with 35486 additions and 0 deletions

View File

@@ -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
};

View File

@@ -0,0 +1,497 @@
/**
* 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();
// SCMVAS/SCMHVAS/VAS/HVAS are Accuracy-only; no binary spec file exists for them.
// Return a sentinel so export-datasheets.js routes them through the SCMVAS template
// instead of skipping on "missing specs".
if (/^(SCMVAS|SCMHVAS|VAS|HVAS)-/.test(key)) {
return { MODNAME: modelNumber.trim(), _family: 'SCMVAS', _noSpecs: true };
}
// 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();
// Order matters: SCMHVAS/SCMVAS must match before generic SCM5B-style.
if (m.startsWith('SCMHVAS') || m.startsWith('SCMVAS') ||
m.startsWith('HVAS') || m.startsWith('VAS-')) return 'SCMVAS';
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 };

View File

@@ -0,0 +1,112 @@
/**
* Parser for Engineering-Tested SCMHVAS pre-rendered .txt datasheets.
*
* Source: TS-3R\LOGS\VASLOG\VASLOG - Engineering Tested\*.txt
* Each file is a complete, human-readable test datasheet. We extract
* metadata for the DB row and keep the full file contents in raw_data
* so the export stage can copy it verbatim to X:\For_Web\<SN>.TXT.
*/
const fs = require('fs');
const path = require('path');
// Filename examples:
// 166590-1.txt -> SN 166590-1
// 166590-110042023104524.txt -> SN 166590-1, timestamp 10042023104524
// 166594-1010042023090444.txt -> SN 166594-10, timestamp 10042023090444
// The trailing MMDDYYYYhhmmss block (14 digits) is optional and must be
// stripped. The SN is the remainder; it always has exactly one dash.
//
// A single greedy regex can't do this reliably because `\d+-\d+` will
// swallow part of the 14-digit timestamp. Split into two steps:
// (1) detect and peel the trailing 14-digit timestamp, then
// (2) validate what remains as a proper SN (`N-N` optionally followed by
// one letter). If the remainder doesn't validate, null the SN so the
// in-file `SN:` header wins.
const SN_RE = /^\d+-\d+[A-Za-z]?$/;
function parseFilename(fileName) {
const base = fileName.replace(/\.txt$/i, '');
if (base === fileName) return null; // not a .txt
const tsMatch = base.match(/^(.+?)(\d{14})$/);
let serialCandidate;
let timestamp;
if (tsMatch) {
serialCandidate = tsMatch[1];
timestamp = tsMatch[2];
} else {
serialCandidate = base;
timestamp = null;
}
const serialNumber = SN_RE.test(serialCandidate) ? serialCandidate : null;
return { serialNumber, timestamp };
}
function extractField(text, label) {
const re = new RegExp('^\\s*' + label + ':\\s*(.+?)\\s*$', 'm');
const m = text.match(re);
return m ? m[1].trim() : null;
}
// MM/DD/YYYY or MM-DD-YYYY -> YYYY-MM-DD (DB canonical)
function normalizeDate(dateStr) {
if (!dateStr) return null;
const m = dateStr.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
if (!m) return null;
const mm = m[1].padStart(2, '0');
const dd = m[2].padStart(2, '0');
return `${m[3]}-${mm}-${dd}`;
}
function extractAccuracyStatus(text) {
// Line format: " Accuracy 0.007% +/- 0.03% PASS"
const m = text.match(/^\s*Accuracy\s+\S+\s+\S+(?:\s+\S+)?\s+(PASS|FAIL)\s*$/mi);
return m ? m[1].toUpperCase() : null;
}
function parseVaslogEngTxt(filePath, testStation = null) {
const records = [];
try {
if (!fs.existsSync(filePath)) return records;
const content = fs.readFileSync(filePath, 'utf8');
const baseName = path.basename(filePath);
const parsedName = parseFilename(baseName);
if (!parsedName) return records;
const modelNumber = extractField(content, 'Model');
const dateRaw = extractField(content, 'Date');
const snFromFile = extractField(content, 'SN');
const testDate = normalizeDate(dateRaw);
const result = extractAccuracyStatus(content) || 'PASS';
if (!modelNumber || !testDate) return records;
// Prefer the in-file SN: header. Fall back to filename-derived SN
// only if it validated against SN_RE (parsedName.serialNumber is
// null on pathological names, which forces the header to win).
const serialNumber = snFromFile || parsedName.serialNumber;
if (!serialNumber) return records;
records.push({
log_type: 'VASLOG_ENG',
model_number: modelNumber.trim(),
serial_number: serialNumber.trim(),
test_date: testDate,
test_station: testStation,
overall_result: result,
raw_data: content,
source_file: filePath,
});
} catch (err) {
console.error(`Error parsing ${filePath}: ${err.message}`);
}
return records;
}
module.exports = { parseVaslogEngTxt, parseFilename };