UNPKG

node-seasonal

Version:

A simple Node.js wrapper for X-13-ARIMA-SEATS, the seasonal adjustment software by the U.S. Census Bureau

249 lines (211 loc) 7.78 kB
"use strict"; const fs = require('fs'); const rimraf = require('rimraf'); const d3 = Object.assign({}, require('d3-dsv')); const { execSync } = require('child_process'); const seasonal = {}; // Return auto-adusted figures appended to input data seasonal.adjust = function(data,opts) { // Set defaults and check inputs seasonal.opts = initOptions(data,opts); // Auto detect dates and update global obj getDateExtent(); // Create an x13 input .spc file for each set of values createInputFiles(); // Run binary for each set of values and append to input data collateSeasonalData(); // Delete temp directory if it exists, and contents cleanUp(seasonal.opts.temp_dir); // Return data with appended seasonal adjustments return seasonal.opts.data; } // Run custom spec file seasonal.custom = function(opts) { seasonal.opts = initCustomOptions(opts); executeX13(); } function initOptions(data,opts) { // Check input data if (!data || !Array.isArray(data) || data.length < 36) { exitError("Input data does not exist, is not array or has fewer than 36 months of data. (Series to be modelled and/or seasonally adjusted must have at least 3 complete years of data.)"); } // Check required props if (!opts.date_field) { exitError("A value is required in the date_field property."); } if (!opts.value_fields || !Array.isArray(opts.value_fields) || opts.value_fields.length <= 0) { exitError("The value_fields property must contain an array of at least one value."); } if (!opts.table_ids || !Array.isArray(opts.table_ids) || opts.table_ids.length <= 0) { exitError("The table_ids property must contain an array of at least one value."); } // Make sure all input data has values above zero for (let value_field of opts.value_fields) { if (Math.min(...data.map(d=>+d[value_field])) <= 0) { exitError("Input data cannot include zero or negative values."); } } // Get current timestamp for output filenames opts.timestamp = Date.now(); // Set log to off by default opts.log = opts.log || false; // Set default temp directory opts.temp_dir = `${__dirname}/temp`; // Add data to retain changes in global obj opts.data = data; // Set output directory if (opts.output_dir) { // If defined output directory doesn't exist, create if (!fs.existsSync(opts.output_dir)) fs.mkdirSync(opts.output_dir); } else { // If no output directory defined, add temp cleanUp(opts.temp_dir); fs.mkdirSync(opts.temp_dir); opts.output_dir = opts.temp_dir; } return opts; } function initCustomOptions(opts) { // Check required props if (!opts.input_file_path) { exitError("No input file specified."); } // Set log to off by default opts.log = opts.log || false; return opts; } // Get range of years and earliest month for use in x13 input file function getDateExtent() { const opts = seasonal.opts; const years = opts.data.map(d=>+getYearFromDate(d[opts.date_field])); // Get min/max years opts.start_year = Math.min(...years); opts.end_year = Math.max(...years); // Get earliest month from earliest year opts.start_month = Math.min(...opts.data .filter(d=>+getYearFromDate(d[opts.date_field])==opts.start_year) .map(d=>+getMonthFromDate(d[opts.date_field]))); // Zero-pad months if (opts.start_month < 10) `0${opts.start_month}`; // Validate detected dates if (+opts.start_month < 1 || +opts.start_month > 12) { exitError("Detected start month was not between 1 and 12."); process.exit(1); } if (opts.start_year.toString().length > 4 || opts.start_year.toString().length < 4) { exitError("Detected start year was not four digits long."); process.exit(1); } if (opts.end_year.toString().length > 4 || opts.end_year.toString().length < 4) { exitError("Detected end year was not four digits long."); process.exit(1); } seasonal.opts = opts; } // Create one input file for each set of values function createInputFiles() { const opts = seasonal.opts; // Create input file for auto-adjustment for (let val_field of opts.value_fields) { let spec = `series {\n` spec += `title = "node-seasonal auto adjust"\n`; spec += `data = (\n`; // x13 expects 12 space-delimited values (representing months) for each year to be adjusted for (let y = opts.start_year; y <= opts.end_year; y++) { spec += opts.data .filter(d=>+getYearFromDate(d[opts.date_field]) == y) .sort((a,b)=>+combineYearMonth(a[opts.date_field]) - +combineYearMonth(b[opts.date_field])) .map(d=>d[val_field]) .join(" ")+"\n"; } spec += `)\n`; spec += `start = ${opts.start_year}.${opts.start_month}\n`; spec += `}\n`; spec += `x11{ save = (${opts.table_ids.join(" ")}) }`; // Save input file try { fs.writeFileSync(`${opts.output_dir}/${opts.timestamp}_${val_field}.spc`,spec,"utf8"); } catch(err) { exitError(err); } } } // For each field and table defined, run x11 to generate data, collect it, append it to input data function collateSeasonalData() { const opts = seasonal.opts; for (let val_field of opts.value_fields) { const seas = getSeasonalData(val_field); for (let table_id of opts.table_ids) { seasonal.opts.data.forEach(d=>{ const adj = seas[table_id].filter(a=>a[opts.date_field]==d[opts.date_field])[0]; d[`${val_field}_${table_id}`] = (adj) ? +adj.val : ""; }); } } } // For this value field, run x13, return object with properties for each table requested function getSeasonalData(val_field) { const opts = seasonal.opts; executeX13(val_field); const obj = {}; for (let table_id of opts.table_ids) obj[table_id] = formatSeasonalData(val_field,table_id); return obj; } // Run x13ashtml binary executable in shell with input file saved above as argument function executeX13(val_field) { const opts = seasonal.opts; // Get user-inputted path or generated one const input_file_path = opts.input_file_path || `${opts.output_dir}/${opts.timestamp}_${val_field}`; // Get binary path based on user OS: could be 'darwin', 'freebsd', 'linux', 'sunos', 'win32' let bin_path = `${__dirname}/x13binary/osx/bin/x13ashtml`; if (process.platform === "win32") bin_path = `${__dirname}/x13binary/win/bin/x13ashtml.exe`; try { let stdout = execSync(`${bin_path} ${input_file_path}`); if (opts.log) console.log(stdout.toString()); if (stdout.toString().includes("ERROR: ")) exitError(stdout.toString()); } catch (err) { exitError(err.message); // console.log(err.status); // console.log(err.message); // console.log(err.stderr.toString()); // console.log(err.stdout.toString()); } } // x13ashtml saves seasonally adjusted data to a tab-separated file function formatSeasonalData(val_field,table_id) { const opts = seasonal.opts; return d3.tsvParse(fs.readFileSync(`${opts.output_dir}/${opts.timestamp}_${val_field}.${table_id}`,"utf8")) .filter(d=>d.date!=="------") .map(d=>{ return { month: d.date.substr(0, 4) + "-" + d.date.substr(4), val: Number.parseFloat(+d[`${opts.timestamp}_${val_field}.${table_id}`]).toFixed(2) } }); } function exitError(message) { console.log(new Error(message)); cleanUp(`${__dirname}/temp`); process.exit(1); } // Delete directory and contents function cleanUp(dir) { if (fs.existsSync(dir)) { try { rimraf.sync(dir); } catch (err) { exitError(err); } } } function getYearFromDate(date) { return date.split("-")[0]; } function getMonthFromDate(date) { return date.split("-")[1]; } function combineYearMonth(date) { return +date.split("-").join(""); } module.exports = seasonal;