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;
}