File size: 9,802 Bytes
ebaf8c2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | /**
* 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;
}
|