ASCEND / model.mjs
mmrech's picture
Launch ASCEND with linked interactive 3D globe
ebaf8c2 verified
Raw History Blame Contribute Delete
9.8 kB
/**
* ASCEND illustrative service-flow model, not a fitted clinical or causal model.
* Units: expected operations and unresolved cases; months; incremental BRL.
* Fractional counts are deliberate expected flows, not individual patient records.
*
* Each month: available = opening unresolved cases + new arrivals;
* completed = min(available, referral throughput, staffed capacity, funded slots).
* Referral throughput = arrivals * min(1, baseline referral + intervention gain).
* Therefore completions cannot exceed arrivals: this model can slow backlog growth
* but cannot clear an existing backlog or estimate waiting times. It has no exits,
* mortality, contraindications, repeat operations, or empirical effect estimates.
*
* An additional slot costs unitCost, bundling staff, theatre service, and funding.
* Theatre/combined purchase capacity and fund it; referral only funds use of
* existing capacity. All intervention budgets are committed expenditure, even
* when other constraints make some purchased slots unused. Programme fixed costs
* are paid in full. Current-service costs are excluded from incremental spending.
*/
export const DEFAULTS = Object.freeze({
budget: 2400000,
capacity: 90,
referralGain: 0.15,
arrivals: 150,
initialQueue: 240,
baseReferral: 0.6,
unitCost: 12000,
referralFixedCost: 600000,
baselineFundedSlots: 100,
months: 12,
});
export const STRATEGIES = Object.freeze([
Object.freeze({ id: 'current', name: 'Current service', color: '#8493a6' }),
Object.freeze({ id: 'theatre', name: 'Expand surgical capacity', color: '#61d8bf' }),
Object.freeze({ id: 'referral', name: 'Strengthen referral', color: '#a99bff' }),
Object.freeze({ id: 'combined', name: 'Combine both', color: '#f4c56a' }),
]);
const BOUNDS = Object.freeze({
budget: [0, 6000000], capacity: [40, 180], referralGain: [0, 0.4],
arrivals: [0, 10000], initialQueue: [0, 1000000], baseReferral: [0, 1],
unitCost: [1, 10000000], referralFixedCost: [0, 6000000],
baselineFundedSlots: [0, 10000], months: [1, 120],
});
const TOLERANCE = 1e-7;
/** Only actual finite numbers are accepted; missing fields use defaults. */
export function normalizeState(input = {}) {
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
throw new TypeError('Model state must be an object of finite numbers.');
}
const state = {};
for (const [key, fallback] of Object.entries(DEFAULTS)) {
const value = Object.hasOwn(input, key) ? input[key] : fallback;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new TypeError(`${key} must be an actual finite number.`);
}
const [lower, upper] = BOUNDS[key];
state[key] = Math.max(lower, Math.min(upper, value));
}
state.months = Math.floor(state.months);
return state;
}
function simulate(state, { additionalSlots = 0, referralGain = 0, expand = false } = {}) {
const capacity = state.capacity + (expand ? additionalSlots : 0);
const fundedSlots = state.baselineFundedSlots + additionalSlots;
const referralThroughput = state.arrivals * Math.min(1, state.baseReferral + referralGain);
const monthly = [];
let queue = state.initialQueue;
let total = 0;
for (let index = 0; index < state.months; index += 1) {
const openingQueue = queue;
const available = openingQueue + state.arrivals;
const completed = Math.min(available, referralThroughput, capacity, fundedSlots);
queue = Math.max(0, available - completed);
total += completed;
monthly.push({
month: index + 1, openingQueue, arrivals: state.arrivals, available,
referralThroughput: Math.min(available, referralThroughput), capacity,
fundedSlots, completed, closingQueue: queue, queue,
});
}
const final = monthly.at(-1);
const limits = [
['Demand', final.available], ['Referral flow', final.referralThroughput],
['Staffed capacity', final.capacity], ['Operating funds', final.fundedSlots],
];
const bindingConstraints = limits.filter(([, limit]) => Math.abs(limit - final.completed) <= TOLERANCE).map(([label]) => label);
const bottleneck = bindingConstraints.includes('Demand') ? 'Demand' : bindingConstraints.join(' + ');
return { completed: total, queue, monthly, bottleneck, bindingConstraints };
}
/** Returns current, theatre, referral, combined; infeasible options are explicit. */
export function evaluate(input = {}) {
const state = normalizeState(input);
const baseline = simulate(state);
return STRATEGIES.map(strategy => {
const isCurrent = strategy.id === 'current';
const usesReferral = strategy.id === 'referral' || strategy.id === 'combined';
const expand = strategy.id === 'theatre' || strategy.id === 'combined';
const feasible = isCurrent || (state.budget > 0 && (!usesReferral || state.budget >= state.referralFixedCost));
const reason = feasible ? '' : state.budget <= 0
? 'No incremental budget is available.'
: 'Budget is below the fixed referral programme cost.';
const programmeCost = !isCurrent && feasible && usesReferral ? state.referralFixedCost : 0;
const cost = !isCurrent && feasible ? state.budget : 0;
const slotExpenditure = Math.max(0, cost - programmeCost);
const additionalSlots = slotExpenditure / state.unitCost / state.months;
const result = isCurrent || !feasible ? baseline : simulate(state, {
additionalSlots,
referralGain: usesReferral ? state.referralGain : 0,
expand,
});
return {
...strategy, ...result, feasible, reason,
incremental: Math.max(0, result.completed - baseline.completed),
cost, programmeCost, slotExpenditure, additionalSlots,
};
});
}
/**
* Maximizes extra completed operations, then minimizes committed cost.
* Exact outcome/cost ties retain input order; tiedIds makes that convention visible.
* This is a scenario ranking, not a clinical recommendation or cost-effectiveness test.
*/
export function recommendation(results) {
if (!Array.isArray(results)) throw new TypeError('Results must be an array.');
const feasible = results.filter(result => result.feasible !== false);
if (!feasible.length) throw new RangeError('At least one feasible result is required.');
for (const result of feasible) {
if (!Number.isFinite(result.incremental) || !Number.isFinite(result.cost)) {
throw new TypeError('Feasible results must have finite incremental outcomes and cost.');
}
}
const bestEffect = Math.max(...feasible.map(result => result.incremental));
const effectTies = feasible.filter(result => bestEffect - result.incremental <= TOLERANCE);
const lowestCost = Math.min(...effectTies.map(result => result.cost));
const ties = effectTies.filter(result => result.cost - lowestCost <= TOLERANCE);
return { ...ties[0], tiedIds: ties.map(result => result.id), effectTiedIds: effectTies.map(result => result.id) };
}
/** Row-major scenario grid. y=0 is no assumed referral improvement. */
export function landscape(input = {}, cols = 35, rows = 25) {
if (![cols, rows].every(value => Number.isInteger(value) && value >= 2 && value <= 200)) {
throw new RangeError('Landscape dimensions must be integers between 2 and 200.');
}
const state = normalizeState(input);
// Referral throughput never exceeds arrivals, so available demand cannot bind
// below it, even with an empty opening queue. All remaining limits are fixed:
// monthly completions are constant, and no monthly history is needed here.
// Keep evaluate()'s detailed simulation for monthly inspection and conservation.
const options = STRATEGIES.map(strategy => {
const isCurrent = strategy.id === 'current';
const usesReferral = strategy.id === 'referral' || strategy.id === 'combined';
const feasible = isCurrent || (state.budget > 0 && (!usesReferral || state.budget >= state.referralFixedCost));
const cost = !isCurrent && feasible ? state.budget : 0;
const programmeCost = !isCurrent && feasible && usesReferral ? state.referralFixedCost : 0;
const additionalSlots = (cost - programmeCost) / state.unitCost / state.months;
return {
...strategy, feasible, cost, additionalSlots,
usesReferral: feasible && usesReferral,
expand: feasible && (strategy.id === 'theatre' || strategy.id === 'combined'),
};
});
const baseThroughput = state.arrivals * state.baseReferral;
const cells = [];
for (let row = 0; row < rows; row += 1) {
const y = 0.4 * row / (rows - 1);
const improvedThroughput = state.arrivals * Math.min(1, state.baseReferral + y);
for (let col = 0; col < cols; col += 1) {
const x = 40 + 140 * col / (cols - 1);
const baselineCompleted = Math.min(baseThroughput, x, state.baselineFundedSlots) * state.months;
const results = options.map(option => {
const monthlyCompleted = Math.min(
option.usesReferral ? improvedThroughput : baseThroughput,
x + (option.expand ? option.additionalSlots : 0),
state.baselineFundedSlots + option.additionalSlots,
);
const completed = monthlyCompleted * state.months;
return {
...option, completed,
incremental: Math.max(0, completed - baselineCompleted),
queue: Math.max(0, state.initialQueue + state.months * (state.arrivals - monthlyCompleted)),
};
});
const winner = recommendation(results);
cells.push({
index: cells.length, col, row, xIndex: col, yIndex: row, x, y,
winnerId: winner.id, winnerName: winner.name, color: winner.color,
incremental: winner.incremental, completed: winner.completed,
cost: winner.cost, queue: winner.queue, tiedIds: winner.tiedIds,
effectTiedIds: winner.effectTiedIds,
});
}
}
return cells;
}