// Parses the raw NASS inputs and computes Washington's share of U.S. commercial apple production. // Usage: node analyze.js // Node.js 20 or later, no packages. Writes results/apples.json. // // Year coverage and precedence (each year comes from exactly one source): // 2000-2002 NASS Noncitrus Fruits and Nuts 2002 Summary (July 2003), apple state tables // 2003-2004 NASS Noncitrus Fruits and Nuts 2005 Summary (July 2006) // 2005-2006 NASS Noncitrus Fruits and Nuts 2007 Summary (July 2008, revised) // 2007-2025 NASS Quick Stats survey data (current, includes all later revisions) // 2026 NASS Quick Stats August 2026 forecast (total production only; reported separately) // Overlapping years in other files are used only as consistency checks. const fs = require('fs'); const path = require('path'); const [raw, out] = process.argv.slice(2); if (!raw || !out) { console.error('usage: node analyze.js '); process.exit(1); } fs.mkdirSync(out, { recursive: true }); const ST = { AL: 'Alabama', AZ: 'Arizona', AR: 'Arkansas', CA: 'California', CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', GA: 'Georgia', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa', KS: 'Kansas', KY: 'Kentucky', ME: 'Maine', MD: 'Maryland', MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', US: 'United States' }; const CODE = Object.fromEntries(Object.entries(ST).map(([k, v]) => [v.toUpperCase(), k])); CODE['US TOTAL'] = 'US'; CODE['OTHER STATES'] = 'OT'; ST.OT = 'Other states (NASS combined, not shown separately)'; const CORE7 = ['CA', 'MI', 'NY', 'OR', 'PA', 'VA', 'WA']; // the states NASS still estimates since 2018 const r1 = (x) => Math.round(x * 10) / 10; const r2 = (x) => Math.round(x * 100) / 100; // ---------- CSV ---------- function parseCsv(t) { t = t.replace(/^/, ''); const rows = []; let row = [], f = '', q = false; for (let i = 0; i < t.length; i++) { const c = t[i]; if (q) { if (c === '"') { if (t[i + 1] === '"') { f += '"'; i++; } else q = false; } else f += c; } else if (c === '"') q = true; else if (c === ',') { row.push(f); f = ''; } else if (c === '\n') { row.push(f); rows.push(row); row = []; f = ''; } else if (c !== '\r') f += c; } if (f || row.length) { row.push(f); rows.push(row); } const h = rows[0]; return rows.slice(1).filter((r) => r.length === h.length).map((r) => Object.fromEntries(h.map((k, i) => [k, r[i]]))); } const num = (v) => { const s = String(v).trim().replace(/,/g, ''); return /^-?\d+(\.\d+)?$/.test(s) ? Number(s) : null; }; // ---------- Fixed-width report tables ---------- // Returns { code: [v1..v6] } for the first table after `title` that matches `years`. function reportTable(text, title, years) { const lines = text.split(/\r?\n/); const start = lines.findIndex((l, i) => l.includes(title) && (lines[i + 1] || '').includes(`${years[0]}-${years[2]}`)); if (start < 0) throw new Error('table not found: ' + title + ' ' + years); const body = []; for (let i = start + 2; i < lines.length; i++) { const l = lines[i]; if (/^\s*Apples, Commercial:/.test(l)) break; const m = l.match(/^([A-Z]{2})( \d\/)?\s*:(.*)$/); if (m) body.push({ code: m[1], cols: m[3], offset: l.indexOf(':') + 1 }); if (m && m[1] === 'US') break; } // Column end positions from the US row (always complete); assign each token to the nearest column. const tokens = (s) => [...s.matchAll(/[\d,.]+/g)].map((t) => ({ v: num(t[0]), end: t.index + t[0].length })); const ref = tokens(body.find((b) => b.code === 'US').cols).map((t) => t.end); if (ref.length !== 6) throw new Error('US row does not have 6 columns in ' + title); const res = {}; for (const b of body) { const vals = [null, null, null, null, null, null]; for (const t of tokens(b.cols)) { let k = 0; ref.forEach((e, j) => { if (Math.abs(e - t.end) < Math.abs(ref[k] - t.end)) k = j; }); if (vals[k] !== null) throw new Error(`column clash ${title} ${b.code}`); vals[k] = t.v; } res[b.code] = vals; } return res; } function fromReport(file, years) { const text = fs.readFileSync(path.join(raw, file), 'utf8'); const acre = reportTable(text, 'Apples, Commercial: Bearing Acreage and Yield', years); const prod = reportTable(text, 'Apples, Commercial: Total and Utilized Production', years); const val = reportTable(text, 'Apples, Commercial: Price and Value by State', years); const rows = []; for (const code of Object.keys(prod)) { years.forEach((y, i) => { const p = prod[code]; if (p[i] == null && p[i + 3] == null) return; // state not estimated that year rows.push({ year: y, code, bearingAcres: acre[code] ? acre[code][i] : null, totalMlb: p[i], utilizedMlb: p[i + 3], valueKusd: val[code] ? val[code][i + 3] : null, sourceFile: file }); }); } return rows; } // ---------- Quick Stats ---------- const qs = parseCsv(fs.readFileSync(path.join(raw, 'quickstats_apples_state_national.csv'), 'utf8')) .filter((r) => r.Period === 'YEAR' && r.Domain === 'TOTAL' && (r['Geo Level'] === 'STATE' || r['Geo Level'] === 'NATIONAL')); const ITEMS = { 'APPLES - ACRES BEARING': ['bearingAcres', 1], 'APPLES - PRODUCTION, MEASURED IN LB': ['totalMlb', 1e-6], 'APPLES, UTILIZED - PRODUCTION, MEASURED IN LB': ['utilizedMlb', 1e-6], 'APPLES, UTILIZED - PRODUCTION, MEASURED IN $': ['valueKusd', 1e-3], 'APPLES, FRESH MARKET - PRODUCTION, MEASURED IN LB': ['freshMlb', 1e-6], }; const qsMap = {}; const suppressed = []; for (const r of qs) { const it = ITEMS[r['Data Item']]; if (!it) continue; const code = CODE[r.State]; if (!code) throw new Error('unknown state ' + r.State); const k = r.Year + '|' + code; qsMap[k] = qsMap[k] || { year: +r.Year, code, sourceFile: 'quickstats_apples_state_national.csv' }; const v = num(r.Value); if (v === null) suppressed.push({ year: +r.Year, code, item: r['Data Item'], value: r.Value.trim() }); qsMap[k][it[0]] = v === null ? null : Math.round(v * it[1] * 1000) / 1000; } // ---------- Assemble state-year table ---------- const reportYears = [ ['NoncFruiNu-07-08-2003_Annual_Summary.txt', [2000, 2001, 2002], [2000, 2001, 2002]], ['NoncFruiNu-07-06-2006_final.txt', [2003, 2004, 2005], [2003, 2004]], ['NoncFruiNu-07-08-2008_revision.txt', [2005, 2006, 2007], [2005, 2006]], ]; const stateYear = []; const crossChecks = []; const reportRows = {}; for (const [file, years, use] of reportYears) { const rows = fromReport(file, years); reportRows[file] = rows; for (const r of rows) if (use.includes(r.year)) stateYear.push({ ...r, freshMlb: null }); } for (const r of Object.values(qsMap)) if (r.year >= 2007 && r.year <= 2025 && r.utilizedMlb != null) stateYear.push(r); stateYear.sort((a, b) => a.year - b.year || (a.code === 'US') - (b.code === 'US') || a.code.localeCompare(b.code)); // Cross-checks: the same year in a second NASS file. const pick = (rows, y, c) => rows.find((r) => r.year === y && r.code === c); for (const [file, y] of [['NoncFruiNu-07-06-2006_final.txt', 2005], ['NoncFruiNu-07-08-2008_revision.txt', 2007]]) { for (const c of ['WA', 'US']) { const a = pick(reportRows[file], y, c); const b = pick(stateYear, y, c); crossChecks.push({ year: y, code: c, check: `${file} vs adopted value`, reportUtilizedMlb: a.utilizedMlb, adoptedUtilizedMlb: b.utilizedMlb, adoptedFrom: b.sourceFile, differenceMlb: r1(a.utilizedMlb - b.utilizedMlb) }); } } { // ncit0526.txt (Noncitrus Fruits and Nuts 2025 Summary, May 2026) vs Quick Stats, 2023-2025 const text = fs.readFileSync(path.join(raw, 'ncit0526.txt'), 'utf8').split(/\r?\n/); const s = text.findIndex((l) => /Total production\s+:\s+Utilized production/.test(l)); for (let i = s; i < s + 20; i++) { const m = text[i].match(/^(Washington|United States) \.+:(.*)$/); if (!m) continue; const v = [...m[2].matchAll(/[\d,.]+/g)].map((t) => num(t[0])); [2023, 2024, 2025].forEach((y, j) => { const c = m[1] === 'Washington' ? 'WA' : 'US'; const b = pick(stateYear, y, c); crossChecks.push({ year: y, code: c, check: 'ncit0526.txt (May 2026 summary) vs Quick Stats', reportUtilizedMlb: v[j + 3], adoptedUtilizedMlb: b.utilizedMlb, adoptedFrom: b.sourceFile, differenceMlb: r1(v[j + 3] - b.utilizedMlb) }); }); } } // ---------- Annual national series ---------- const years = [...new Set(stateYear.map((r) => r.year))]; const annual = years.map((y) => { const rows = stateYear.filter((r) => r.year === y); const us = rows.find((r) => r.code === 'US'); const wa = rows.find((r) => r.code === 'WA'); const states = rows.filter((r) => r.code !== 'US' && r.code !== 'OT' && r.utilizedMlb != null).sort((a, b) => b.utilizedMlb - a.utilizedMlb); const other = rows.find((r) => r.code === 'OT'); const sumStates = states.reduce((s, r) => s + r.utilizedMlb, 0) + (other && other.utilizedMlb ? other.utilizedMlb : 0); const core = states.filter((r) => CORE7.includes(r.code)); const coreSum = core.reduce((s, r) => s + r.utilizedMlb, 0); const second = states[1]; return { year: y, statesEstimated: states.length, usUtilizedMlb: us.utilizedMlb, waUtilizedMlb: wa.utilizedMlb, waShareUtilizedPct: r2(100 * wa.utilizedMlb / us.utilizedMlb), usTotalMlb: us.totalMlb, waTotalMlb: wa.totalMlb, waShareTotalPct: us.totalMlb && wa.totalMlb ? r2(100 * wa.totalMlb / us.totalMlb) : null, usValueKusd: us.valueKusd, waValueKusd: wa.valueKusd, waShareValuePct: us.valueKusd && wa.valueKusd ? r2(100 * wa.valueKusd / us.valueKusd) : null, usBearingAcres: us.bearingAcres, waBearingAcres: wa.bearingAcres, waShareBearingAcresPct: us.bearingAcres && wa.bearingAcres ? r2(100 * wa.bearingAcres / us.bearingAcres) : null, usFreshMlb: us.freshMlb ?? null, waFreshMlb: wa.freshMlb ?? null, waShareFreshPct: us.freshMlb && wa.freshMlb ? r2(100 * wa.freshMlb / us.freshMlb) : null, rank1: states[0].code, secondState: second.code, secondUtilizedMlb: second.utilizedMlb, waToSecondRatio: r2(wa.utilizedMlb / second.utilizedMlb), restOfUsUtilizedMlb: r1(us.utilizedMlb - wa.utilizedMlb), sevenStateUtilizedMlb: r1(coreSum), waShareOfSevenStatesPct: r2(100 * wa.utilizedMlb / coreSum), otherStatesUtilizedMlb: r1(sumStates - coreSum), usMinusSumOfStatesMlb: r1(us.utilizedMlb - sumStates), }; }); // ---------- Saved-analysis results ---------- const A = {}; A.shareSeries = annual.flatMap((a) => [ { year: a.year, series: 'Share of utilized production', sharePct: a.waShareUtilizedPct }, ...(a.waShareValuePct != null ? [{ year: a.year, series: 'Share of crop value', sharePct: a.waShareValuePct }] : []), ]); const shares = annual.map((a) => a.waShareUtilizedPct); const minA = annual.reduce((m, a) => (a.waShareUtilizedPct < m.waShareUtilizedPct ? a : m)); const maxA = annual.reduce((m, a) => (a.waShareUtilizedPct > m.waShareUtilizedPct ? a : m)); const mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length; const last5 = annual.filter((a) => a.year >= 2021); const first5 = annual.filter((a) => a.year <= 2004); A.summary = { years: `${annual[0].year}-${annual[annual.length - 1].year}`, nYears: annual.length, yearsAbove50Pct: annual.filter((a) => a.waShareUtilizedPct > 50).length, minSharePct: minA.waShareUtilizedPct, minYear: minA.year, maxSharePct: maxA.waShareUtilizedPct, maxYear: maxA.year, meanSharePct: r2(mean(shares)), mean2000to2004Pct: r2(mean(first5.map((a) => a.waShareUtilizedPct))), mean2021to2025Pct: r2(mean(last5.map((a) => a.waShareUtilizedPct))), latestYear: 2025, latestSharePct: annual.find((a) => a.year === 2025).waShareUtilizedPct, latestWaUtilizedMlb: annual.find((a) => a.year === 2025).waUtilizedMlb, latestUsUtilizedMlb: annual.find((a) => a.year === 2025).usUtilizedMlb, yearsWaRankedFirst: annual.filter((a) => a.rank1 === 'WA').length, minWaToSecondRatio: Math.min(...annual.map((a) => a.waToSecondRatio)), minRatioYear: annual.find((a) => a.waToSecondRatio === Math.min(...annual.map((x) => x.waToSecondRatio))).year, }; A.ranking2025 = stateYear.filter((r) => r.year === 2025 && r.code !== 'US' && r.code !== 'OT').sort((a, b) => b.utilizedMlb - a.utilizedMlb) .map((r, i) => ({ rank: i + 1, state: ST[r.code], utilizedMlb: r.utilizedMlb, sharePct: r2(100 * r.utilizedMlb / annual.find((a) => a.year === 2025).usUtilizedMlb), valueKusd: r.valueKusd, bearingAcres: r.bearingAcres })); A.secondState = annual.map((a) => ({ year: a.year, secondState: ST[a.secondState], secondUtilizedMlb: a.secondUtilizedMlb, waUtilizedMlb: a.waUtilizedMlb, waToSecondRatio: a.waToSecondRatio })); A.sevenState = annual.map((a) => ({ year: a.year, statesEstimated: a.statesEstimated, waShareUtilizedPct: a.waShareUtilizedPct, waShareOfSevenStatesPct: a.waShareOfSevenStatesPct, otherStatesUtilizedMlb: a.otherStatesUtilizedMlb, otherStatesSharePct: r2(100 * a.otherStatesUtilizedMlb / a.usUtilizedMlb) })); A.valueVsVolume = annual.map((a) => ({ year: a.year, waShareUtilizedPct: a.waShareUtilizedPct, waShareValuePct: a.waShareValuePct, waShareBearingAcresPct: a.waShareBearingAcresPct })); A.fresh = annual.filter((a) => a.waShareFreshPct != null).map((a) => ({ year: a.year, waFreshMlb: a.waFreshMlb, usFreshMlb: a.usFreshMlb, waShareFreshPct: a.waShareFreshPct })); A.fresh2021to2025 = { minPct: Math.min(...A.fresh.filter((f) => f.year >= 2021).map((f) => f.waShareFreshPct)), maxPct: Math.max(...A.fresh.filter((f) => f.year >= 2021).map((f) => f.waShareFreshPct)) }; // Census of Agriculture: acreage on all farms in every state (not limited to commercial orchards). const census = parseCsv(fs.readFileSync(path.join(raw, 'quickstats_census_apples.csv'), 'utf8')) .filter((r) => r.Domain === 'TOTAL' && r.Period === 'YEAR' && ['APPLES - ACRES BEARING', 'APPLES - ACRES BEARING & NON-BEARING'].includes(r['Data Item']) && ['STATE', 'NATIONAL'].includes(r['Geo Level'])); A.census = [2002, 2007, 2012, 2017, 2022].map((y) => { const g = (state, item) => { const r = census.find((x) => +x.Year === y && x.State === state && x['Data Item'] === item); return r ? { v: num(r.Value), cv: num(r['CV (%)']) } : { v: null, cv: null }; }; const usB = g('US TOTAL', 'APPLES - ACRES BEARING'), waB = g('WASHINGTON', 'APPLES - ACRES BEARING'); const usT = g('US TOTAL', 'APPLES - ACRES BEARING & NON-BEARING'), waT = g('WASHINGTON', 'APPLES - ACRES BEARING & NON-BEARING'); const statesB = census.filter((x) => +x.Year === y && x['Geo Level'] === 'STATE' && x['Data Item'] === 'APPLES - ACRES BEARING' && num(x.Value) != null).sort((a, b) => num(b.Value) - num(a.Value)); return { year: y, usBearingAcres: usB.v, waBearingAcres: waB.v, waBearingCvPct: waB.cv, waShareBearingPct: r2(100 * waB.v / usB.v), usTotalAcres: usT.v, waTotalAcres: waT.v, waShareTotalAcresPct: r2(100 * waT.v / usT.v), secondState: statesB[1].State, secondBearingAcres: num(statesB[1].Value), statesWithBearingAcres: statesB.length }; }); // 2026 forecast (total production, Quick Stats; NASS August 2026 Crop Production per ERS FTS-386). const f26 = (c) => qsMap['2026|' + c]; A.forecast2026 = { waTotalMlb: f26('WA').totalMlb, usTotalMlb: f26('US').totalMlb, waShareTotalPct: r2(100 * f26('WA').totalMlb / f26('US').totalMlb), statesForecast: Object.keys(qsMap).filter((k) => k.startsWith('2026|') && !k.endsWith('|US')).length }; // Since 2018 NASS estimates only seven states, and its U.S. total is the sum of those seven. // Sensitivity: add back the 2015-2017 average utilized production of all other states. { const base = annual.filter((a) => a.year >= 2015 && a.year <= 2017); const otherMean = r1(mean(base.map((a) => a.otherStatesUtilizedMlb))); A.adjusted = { otherStatesMeanMlb2015to2017: otherMean, rows: annual.filter((a) => a.year >= 2018).map((a) => ({ year: a.year, waShareUtilizedPct: a.waShareUtilizedPct, adjustedUsUtilizedMlb: r1(a.usUtilizedMlb + otherMean), adjustedSharePct: r2(100 * a.waUtilizedMlb / (a.usUtilizedMlb + otherMean)) })) }; A.adjusted.minAdjustedPct = Math.min(...A.adjusted.rows.map((r) => r.adjustedSharePct)); A.adjusted.maxAdjustedPct = Math.max(...A.adjusted.rows.map((r) => r.adjustedSharePct)); } fs.writeFileSync(path.join(out, 'apples.json'), JSON.stringify({ stateYear, annual, crossChecks, suppressed, analyses: A }, null, 1) + '\n'); console.log(JSON.stringify(A.summary, null, 1)); console.log(annual.map((a) => [a.year, a.statesEstimated, a.waUtilizedMlb, a.usUtilizedMlb, a.waShareUtilizedPct, a.waShareValuePct, a.waShareBearingAcresPct, a.secondState, a.waToSecondRatio, a.waShareOfSevenStatesPct, a.otherStatesUtilizedMlb, a.usMinusSumOfStatesMlb, a.waShareFreshPct].join('\t')).join('\n')); console.log(JSON.stringify({ crossChecks, census: A.census, f26: A.forecast2026, suppressed: suppressed.length, adjusted: A.adjusted, fresh: A.fresh2021to2025 }, null, 0));