// The calculate tool's arithmetic: lines of `name = expression`, each able to use the names before it, // worked out exactly as written so a model never does sums in its head. Numbers, + - * / and ^ (or // **), parentheses, and a few functions, npv and irr among them. Read by a small parser, never // evaluated as code. Pure, so it is tested alone. export interface CalcLine { name: string expression: string value: number | null error: string | null } export interface Calculation { lines: CalcLine[] /** What the model reads: each line as written and its value, or why it has none. */ text: string } const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ export function calculate(source: string): Calculation { const known = new Map() const lines: CalcLine[] = [] const written = source .split(/[\n;]/) .map((l) => l.trim()) .filter(Boolean) written.forEach((raw, i) => { const eq = raw.indexOf('=') const named = eq > 0 && NAME.test(raw.slice(0, eq).trim()) const name = named ? raw.slice(0, eq).trim() : `line ${i + 1}` const expression = named ? raw.slice(eq + 1).trim() : raw try { const value = evaluate(expression, known) if (!Number.isFinite(value)) throw new Error(`${expression} is not a finite number.`) known.set(name, value) lines.push({ name, expression, value, error: null }) } catch (err) { lines.push({ name, expression, value: null, error: err instanceof Error ? err.message : String(err) }) } }) const text = lines.length === 0 ? 'Nothing to calculate: write one expression a line, like gm = (rev - cost) / rev.' : written.map((raw, i) => `${raw} → ${lines[i]!.error ?? format(lines[i]!.value!)}`).join('\n') return { lines, text } } /** Ten significant digits: enough for any rounding a question asks, short enough to read. */ function format(value: number): string { return String(Number(value.toPrecision(10))) } type Token = { kind: 'num'; value: number } | { kind: 'name'; value: string } | { kind: 'op'; value: string } function tokens(expression: string): Token[] { const out: Token[] = [] const re = /\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)|([A-Za-z_][A-Za-z0-9_]*)|(\*\*|[-+*/^(),]))/y let at = 0 while (at < expression.length) { if (/^\s*$/.test(expression.slice(at))) break re.lastIndex = at const m = re.exec(expression) if (!m) throw new Error(`${expression.slice(at).trim()} is not something to calculate.`) if (m[1] !== undefined) out.push({ kind: 'num', value: Number(m[1]) }) else if (m[2] !== undefined) out.push({ kind: 'name', value: m[2] }) else out.push({ kind: 'op', value: m[3] === '**' ? '^' : m[3]! }) at = re.lastIndex } return out } const FUNCTIONS: Record number> = { abs: one(Math.abs), sqrt: one(Math.sqrt), ln: one(Math.log), log10: one(Math.log10), exp: one(Math.exp), round: (a) => { if (a.length < 1 || a.length > 2) throw new Error('round takes a number and, if you like, its decimals.') const f = 10 ** (a[1] ?? 0) return Math.round(a[0]! * f) / f }, min: many(Math.min), max: many(Math.max), sum: many((...a) => a.reduce((s, x) => s + x, 0)), avg: many((...a) => a.reduce((s, x) => s + x, 0) / a.length), npv: (a) => { if (a.length < 2) throw new Error('npv takes a rate, then the cash flows from time zero.') return npv(a[0]!, a.slice(1)) }, irr: (a) => irr(a), } function one(f: (x: number) => number): (args: number[]) => number { return (a) => { if (a.length !== 1) throw new Error('This function takes one number.') return f(a[0]!) } } function many(f: (...xs: number[]) => number): (args: number[]) => number { return (a) => { if (a.length === 0) throw new Error('This function takes at least one number.') return f(...a) } } function npv(rate: number, flows: number[]): number { return flows.reduce((s, cf, t) => s + cf / (1 + rate) ** t, 0) } /** The rate between -99% and 10,000% at which the flows' npv is zero, found by halving. */ function irr(flows: number[]): number { if (flows.length < 2) throw new Error('irr takes the cash flows from time zero, at least two.') let lo = -0.99 let hi = 100 if (Math.sign(npv(lo, flows)) === Math.sign(npv(hi, flows))) { throw new Error('There is no rate that makes these cash flows worth zero: they need a change of sign.') } for (let i = 0; i < 200; i++) { const mid = (lo + hi) / 2 if (Math.sign(npv(mid, flows)) === Math.sign(npv(lo, flows))) lo = mid else hi = mid } return (lo + hi) / 2 } /** Recursive descent: sum := product (± product)*; product := unary (*|/ unary)*; unary := -unary | power; power := atom (^ unary)? */ function evaluate(expression: string, known: Map): number { const list = tokens(expression) let i = 0 const peek = (): Token | undefined => list[i] const isOp = (v: string): boolean => peek()?.kind === 'op' && peek()!.value === v const expect = (v: string): void => { if (!isOp(v)) throw new Error(`${expression}: expected "${v}".`) i++ } const sum = (): number => { let v = product() while (isOp('+') || isOp('-')) v = list[i++]!.value === '+' ? v + product() : v - product() return v } const product = (): number => { let v = unary() while (isOp('*') || isOp('/')) v = list[i++]!.value === '*' ? v * unary() : v / unary() return v } const unary = (): number => { if (isOp('-')) { i++ return -unary() } if (isOp('+')) { i++ return unary() } return power() } const power = (): number => { const base = atom() if (!isOp('^')) return base i++ return base ** unary() } const atom = (): number => { const t = list[i++] if (!t) throw new Error(`${expression}: expected a number.`) if (t.kind === 'num') return t.value if (t.kind === 'op' && t.value === '(') { const v = sum() expect(')') return v } if (t.kind === 'name') { if (isOp('(')) { const f = FUNCTIONS[t.value.toLowerCase()] if (!f) throw new Error(`${t.value} is not a function: the functions are ${Object.keys(FUNCTIONS).join(', ')}.`) i++ const args: number[] = [] if (!isOp(')')) { args.push(sum()) while (isOp(',')) { i++ args.push(sum()) } } expect(')') return f(args) } const v = known.get(t.value) if (v === undefined) throw new Error(`${t.value} is not defined above.`) return v } throw new Error(`${expression}: expected a number.`) } const v = sum() if (i < list.length) throw new Error(`${expression}: did not expect "${String(list[i]!.value)}".`) return v }