heor-agent-mcp 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,16 @@
6
6
  * 2. Modify assumptions
7
7
  * 3. Submit to HTA bodies
8
8
  *
9
- * Workbooks use formulas (not static values) so inputs can be changed and
10
- * results recalculate. Multi-tab structure: Inputs, Model, Results, PSA, Audit.
9
+ * Workbooks use live Excel formulas so inputs can be changed and
10
+ * results recalculate. Multi-tab structure: Summary, Inputs, Transition Matrix,
11
+ * Markov Trace, PSA, CEAC, Audit.
11
12
  */
12
13
  import type { CEModelParams, CEModelResult } from "../providers/types.js";
13
14
  import type { AuditRecord } from "../audit/types.js";
14
15
  /**
15
16
  * Build a cost-effectiveness model Excel workbook.
16
- * Inputs are editable (yellow), formulas are read-only (blue).
17
+ * Inputs are editable (yellow); formula cells (blue) update automatically
18
+ * when Inputs values are changed and the workbook is saved/recalculated.
17
19
  */
18
20
  export declare function ceModelToXlsx(params: CEModelParams, result: CEModelResult, audit: AuditRecord): Promise<Buffer>;
19
21
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"xlsx.d.ts","sourceRoot":"","sources":["../../src/formatters/xlsx.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAsCrD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,MAAM,CAAC,CAgUjB;AAED;;GAEG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,OAAO,EAAE,KAAK,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,mBAAmB,EAAE,MAAM,CAAC;IAC5B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,MAAM,CAAC;CAC1B,CAAC,EACF,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,MAAM,CAAC,CAyLjB"}
1
+ {"version":3,"file":"xlsx.d.ts","sourceRoot":"","sources":["../../src/formatters/xlsx.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AA6CrD;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,MAAM,CAAC,CAipBjB;AAED;;GAEG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,OAAO,EAAE,KAAK,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,mBAAmB,EAAE,MAAM,CAAC;IAC5B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,MAAM,CAAC;CAC1B,CAAC,EACF,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,MAAM,CAAC,CAqNjB"}
@@ -6,8 +6,9 @@
6
6
  * 2. Modify assumptions
7
7
  * 3. Submit to HTA bodies
8
8
  *
9
- * Workbooks use formulas (not static values) so inputs can be changed and
10
- * results recalculate. Multi-tab structure: Inputs, Model, Results, PSA, Audit.
9
+ * Workbooks use live Excel formulas so inputs can be changed and
10
+ * results recalculate. Multi-tab structure: Summary, Inputs, Transition Matrix,
11
+ * Markov Trace, PSA, CEAC, Audit.
11
12
  */
12
13
  import ExcelJS from "exceljs";
13
14
  const HEADER_FILL = {
@@ -41,9 +42,19 @@ function styleHeaderCell(cell) {
41
42
  right: { style: "thin" },
42
43
  };
43
44
  }
45
+ function getNCycles(time_horizon) {
46
+ if (time_horizon === "lifetime")
47
+ return 40;
48
+ if (time_horizon === "5yr")
49
+ return 5;
50
+ if (time_horizon === "10yr")
51
+ return 10;
52
+ return Number(time_horizon);
53
+ }
44
54
  /**
45
55
  * Build a cost-effectiveness model Excel workbook.
46
- * Inputs are editable (yellow), formulas are read-only (blue).
56
+ * Inputs are editable (yellow); formula cells (blue) update automatically
57
+ * when Inputs values are changed and the workbook is saved/recalculated.
47
58
  */
48
59
  export async function ceModelToXlsx(params, result, audit) {
49
60
  const wb = new ExcelJS.Workbook();
@@ -52,6 +63,15 @@ export async function ceModelToXlsx(params, result, audit) {
52
63
  const perspective = params.perspective;
53
64
  const currency = perspective === "nhs" ? "GBP" : "USD";
54
65
  const symbol = perspective === "nhs" ? "£" : "$";
66
+ const n_cycles = getNCycles(params.time_horizon);
67
+ // Precompute transition probabilities (needed for result caching in formula cells)
68
+ const efficacyDelta = Math.max(0, Math.min(0.999, params.clinical_inputs.efficacy_delta));
69
+ const mortalityReduction = params.clinical_inputs.mortality_reduction ?? 0;
70
+ const baseMortality = 0.02;
71
+ const interventionMortality = Math.max(0.005, baseMortality * (1 - mortalityReduction));
72
+ const comparatorMortality = baseMortality;
73
+ const probStayOnIntervention = Math.max(0.05, Math.min(0.93, 0.5 + efficacyDelta * 0.5));
74
+ const baselineProbStayOn = Math.max(0.05, Math.min(0.88, probStayOnIntervention * 0.7));
55
75
  // --- Tab 1: Summary ---
56
76
  const summary = wb.addWorksheet("Summary");
57
77
  summary.columns = [
@@ -60,7 +80,7 @@ export async function ceModelToXlsx(params, result, audit) {
60
80
  ];
61
81
  styleHeaderCell(summary.getCell("A1"));
62
82
  styleHeaderCell(summary.getCell("B1"));
63
- const rows = [
83
+ const staticRows = [
64
84
  ["Intervention", params.intervention],
65
85
  ["Comparator", params.comparator],
66
86
  ["Indication", params.indication],
@@ -70,34 +90,79 @@ export async function ceModelToXlsx(params, result, audit) {
70
90
  ["Model Type", params.model_type ?? "markov"],
71
91
  ["Discount Rate", 0.035],
72
92
  ["", ""],
73
- [
74
- "ICER",
75
- isFinite(result.base_case.icer) ? result.base_case.icer : "Dominated",
76
- ],
77
- ["Delta Cost", result.base_case.delta_cost],
78
- ["Delta QALY", result.base_case.delta_qaly],
79
- ["Incremental Life Years", result.base_case.incremental_lys],
80
- ["Total Cost Intervention", result.base_case.total_cost_intervention],
81
- ["Total Cost Comparator", result.base_case.total_cost_comparator],
82
- ["Total QALYs Intervention", result.base_case.total_qaly_intervention],
83
- ["Total QALYs Comparator", result.base_case.total_qaly_comparator],
84
93
  ];
85
- rows.forEach(([metric, value], i) => {
94
+ staticRows.forEach(([metric, value], i) => {
86
95
  const row = summary.getRow(i + 2);
87
96
  row.getCell(1).value = metric;
88
97
  row.getCell(2).value = value;
89
- if ((typeof value === "number" && metric.includes("Cost")) ||
90
- metric === "ICER") {
91
- row.getCell(2).numFmt = `"${symbol}"#,##0`;
92
- }
93
- else if (typeof value === "number" &&
94
- (metric.includes("QALY") || metric.includes("Life Years"))) {
95
- row.getCell(2).numFmt = "0.000";
96
- }
97
- else if (metric === "Discount Rate") {
98
+ if (metric === "Discount Rate") {
98
99
  row.getCell(2).numFmt = "0.0%";
99
100
  }
100
101
  });
102
+ // Rows 11-18: formula cells referencing Markov Trace
103
+ const lastTraceRow = 2 + n_cycles; // row 2 = cycle 0, row 2+n_cycles = last cycle
104
+ // Row 11: ICER
105
+ summary.getRow(11).getCell(1).value = "ICER";
106
+ summary.getRow(11).getCell(2).value = {
107
+ formula: `=IF(B13=0,"Dominated",B12/B13)`,
108
+ result: isFinite(result.base_case.icer)
109
+ ? result.base_case.icer
110
+ : "Dominated",
111
+ };
112
+ summary.getRow(11).getCell(2).numFmt = `"${symbol}"#,##0`;
113
+ summary.getRow(11).getCell(2).fill = FORMULA_FILL;
114
+ // Row 12: Delta Cost
115
+ summary.getRow(12).getCell(1).value = "Delta Cost";
116
+ summary.getRow(12).getCell(2).value = {
117
+ formula: `=B15-B16`,
118
+ result: result.base_case.delta_cost,
119
+ };
120
+ summary.getRow(12).getCell(2).numFmt = `"${symbol}"#,##0`;
121
+ summary.getRow(12).getCell(2).fill = FORMULA_FILL;
122
+ // Row 13: Delta QALY
123
+ summary.getRow(13).getCell(1).value = "Delta QALY";
124
+ summary.getRow(13).getCell(2).value = {
125
+ formula: `=B17-B18`,
126
+ result: result.base_case.delta_qaly,
127
+ };
128
+ summary.getRow(13).getCell(2).numFmt = "0.000";
129
+ summary.getRow(13).getCell(2).fill = FORMULA_FILL;
130
+ // Row 14: Incremental Life Years — keep static (complex lifecycle, minor value)
131
+ summary.getRow(14).getCell(1).value = "Incremental Life Years";
132
+ summary.getRow(14).getCell(2).value = result.base_case.incremental_lys;
133
+ summary.getRow(14).getCell(2).numFmt = "0.000";
134
+ // Row 15: Total Cost Intervention
135
+ summary.getRow(15).getCell(1).value = "Total Cost Intervention";
136
+ summary.getRow(15).getCell(2).value = {
137
+ formula: `=SUM('Markov Trace'!$I$2:$I$${lastTraceRow})`,
138
+ result: result.base_case.total_cost_intervention,
139
+ };
140
+ summary.getRow(15).getCell(2).numFmt = `"${symbol}"#,##0`;
141
+ summary.getRow(15).getCell(2).fill = FORMULA_FILL;
142
+ // Row 16: Total Cost Comparator
143
+ summary.getRow(16).getCell(1).value = "Total Cost Comparator";
144
+ summary.getRow(16).getCell(2).value = {
145
+ formula: `=SUM('Markov Trace'!$J$2:$J$${lastTraceRow})`,
146
+ result: result.base_case.total_cost_comparator,
147
+ };
148
+ summary.getRow(16).getCell(2).numFmt = `"${symbol}"#,##0`;
149
+ summary.getRow(16).getCell(2).fill = FORMULA_FILL;
150
+ // Row 17: Total QALYs Intervention
151
+ summary.getRow(17).getCell(1).value = "Total QALYs Intervention";
152
+ summary.getRow(17).getCell(2).value = {
153
+ formula: `=SUM('Markov Trace'!$L$2:$L$${lastTraceRow})`,
154
+ result: result.base_case.total_qaly_intervention,
155
+ };
156
+ summary.getRow(17).getCell(2).numFmt = "0.000";
157
+ summary.getRow(17).getCell(2).fill = FORMULA_FILL;
158
+ // Row 18: Total QALYs Comparator
159
+ summary.getRow(18).getCell(1).value = "Total QALYs Comparator";
160
+ summary.getRow(18).getCell(2).value = {
161
+ formula: `=SUM('Markov Trace'!$M$2:$M$${lastTraceRow})`,
162
+ result: result.base_case.total_qaly_comparator,
163
+ };
164
+ summary.getRow(18).getCell(2).numFmt = "0.000";
165
+ summary.getRow(18).getCell(2).fill = FORMULA_FILL;
101
166
  // --- Tab 2: Inputs (editable) ---
102
167
  const inputs = wb.addWorksheet("Inputs");
103
168
  inputs.columns = [
@@ -159,6 +224,11 @@ export async function ceModelToXlsx(params, result, audit) {
159
224
  ["Discount rate (costs)", 0.035, "annual %", "NICE reference case"],
160
225
  ["Discount rate (outcomes)", 0.035, "annual %", "NICE reference case"],
161
226
  ];
227
+ // Inputs row mapping (1-based after header):
228
+ // B2 = drug_cost_annual, B3 = comparator_cost_annual, B4 = admin_cost, B5 = ae_cost
229
+ // B6 = efficacy_delta, B7 = mortality_reduction
230
+ // B8 = qaly_on_treatment, B9 = qaly_comparator
231
+ // B10 = discount_rate_costs, B11 = discount_rate_outcomes
162
232
  inputRows.forEach(([param, value, unit, notes], i) => {
163
233
  const row = inputs.getRow(i + 2);
164
234
  row.getCell(1).value = param;
@@ -174,21 +244,12 @@ export async function ceModelToXlsx(params, result, audit) {
174
244
  row.getCell(2).numFmt = "0.000";
175
245
  });
176
246
  inputs.getRow(inputRows.length + 3).getCell(1).value =
177
- "Inputs shown for transparency. To re-run with modified values, call the cost_effectiveness_model tool again editing this sheet does not recalculate results.";
247
+ "Inputs are linked to the Markov Trace, Transition Matrix, Results, and CEAC sheetsedit any value and save to trigger Excel recalculation.";
178
248
  inputs.getRow(inputRows.length + 3).getCell(1).font = {
179
249
  italic: true,
180
250
  color: { argb: "FF666666" },
181
251
  };
182
- // --- Tab 3: Transition Matrix ---
183
- // Derive actual transition probabilities from model params (same logic as
184
- // src/models/modelUtils.ts buildMarkovParamsFromCE).
185
- const efficacyDelta = Math.max(0, Math.min(0.999, params.clinical_inputs.efficacy_delta));
186
- const mortalityReduction = params.clinical_inputs.mortality_reduction ?? 0;
187
- const baseMortality = 0.02;
188
- const interventionMortality = Math.max(0.005, baseMortality * (1 - mortalityReduction));
189
- const comparatorMortality = baseMortality;
190
- const probStayOnIntervention = Math.max(0.05, Math.min(0.93, 0.5 + efficacyDelta * 0.5));
191
- const baselineProbStayOn = Math.max(0.05, Math.min(0.88, probStayOnIntervention * 0.7));
252
+ // --- Tab 3: Transition Matrix (formula cells referencing Inputs) ---
192
253
  const trans = wb.addWorksheet("Transition Matrix");
193
254
  trans.columns = [
194
255
  { header: "From \\ To", key: "state", width: 20 },
@@ -197,54 +258,284 @@ export async function ceModelToXlsx(params, result, audit) {
197
258
  { header: "Dead", key: "dead", width: 16 },
198
259
  ];
199
260
  ["A1", "B1", "C1", "D1"].forEach((c) => styleHeaderCell(trans.getCell(c)));
200
- trans.getRow(2).values = [
201
- "On-Treatment (Intervention)",
202
- probStayOnIntervention,
203
- Math.max(0, 1 - probStayOnIntervention - interventionMortality),
204
- interventionMortality,
205
- ];
206
- trans.getRow(3).values = [
207
- "Off-Treatment (Intervention)",
208
- 0.05,
209
- Math.max(0, 0.95 - interventionMortality),
210
- interventionMortality,
211
- ];
261
+ // Row 2: On-Treatment (Intervention)
262
+ trans.getRow(2).getCell(1).value = "On-Treatment (Intervention)";
263
+ trans.getRow(2).getCell(2).value = {
264
+ formula: `=MIN(0.93,MAX(0.05,0.5+Inputs!$B$6*0.5))`,
265
+ result: probStayOnIntervention,
266
+ };
267
+ trans.getRow(2).getCell(2).fill = FORMULA_FILL;
268
+ trans.getRow(2).getCell(3).value = {
269
+ formula: `=MAX(0,1-B2-MAX(0.005,0.02*(1-Inputs!$B$7)))`,
270
+ result: Math.max(0, 1 - probStayOnIntervention - interventionMortality),
271
+ };
272
+ trans.getRow(2).getCell(3).fill = FORMULA_FILL;
273
+ trans.getRow(2).getCell(4).value = {
274
+ formula: `=MAX(0.005,0.02*(1-Inputs!$B$7))`,
275
+ result: interventionMortality,
276
+ };
277
+ trans.getRow(2).getCell(4).fill = FORMULA_FILL;
278
+ // Row 3: Off-Treatment (Intervention)
279
+ trans.getRow(3).getCell(1).value = "Off-Treatment (Intervention)";
280
+ trans.getRow(3).getCell(2).value = {
281
+ formula: `=0.05`,
282
+ result: 0.05,
283
+ };
284
+ trans.getRow(3).getCell(2).fill = FORMULA_FILL;
285
+ trans.getRow(3).getCell(3).value = {
286
+ formula: `=MAX(0,0.95-MAX(0.005,0.02*(1-Inputs!$B$7)))`,
287
+ result: Math.max(0, 0.95 - interventionMortality),
288
+ };
289
+ trans.getRow(3).getCell(3).fill = FORMULA_FILL;
290
+ trans.getRow(3).getCell(4).value = {
291
+ formula: `=MAX(0.005,0.02*(1-Inputs!$B$7))`,
292
+ result: interventionMortality,
293
+ };
294
+ trans.getRow(3).getCell(4).fill = FORMULA_FILL;
295
+ // Row 4: Dead (Intervention) — static
212
296
  trans.getRow(4).values = ["Dead", 0, 0, 1];
297
+ // Row 5: empty separator
213
298
  trans.getRow(5).values = [""];
214
- trans.getRow(6).values = [
215
- "On-Treatment (Comparator)",
216
- baselineProbStayOn,
217
- Math.max(0, 1 - baselineProbStayOn - comparatorMortality),
218
- comparatorMortality,
219
- ];
220
- trans.getRow(7).values = [
221
- "Off-Treatment (Comparator)",
222
- 0.05,
223
- Math.max(0, 0.95 - comparatorMortality),
224
- comparatorMortality,
225
- ];
299
+ // Row 6: On-Treatment (Comparator)
300
+ trans.getRow(6).getCell(1).value = "On-Treatment (Comparator)";
301
+ trans.getRow(6).getCell(2).value = {
302
+ formula: `=MIN(0.88,MAX(0.05,MIN(0.93,MAX(0.05,0.5+Inputs!$B$6*0.5))*0.7))`,
303
+ result: baselineProbStayOn,
304
+ };
305
+ trans.getRow(6).getCell(2).fill = FORMULA_FILL;
306
+ trans.getRow(6).getCell(3).value = {
307
+ formula: `=MAX(0,1-B6-0.02)`,
308
+ result: Math.max(0, 1 - baselineProbStayOn - comparatorMortality),
309
+ };
310
+ trans.getRow(6).getCell(3).fill = FORMULA_FILL;
311
+ trans.getRow(6).getCell(4).value = 0.02;
312
+ // Row 7: Off-Treatment (Comparator)
313
+ trans.getRow(7).getCell(1).value = "Off-Treatment (Comparator)";
314
+ trans.getRow(7).getCell(2).value = {
315
+ formula: `=0.05`,
316
+ result: 0.05,
317
+ };
318
+ trans.getRow(7).getCell(2).fill = FORMULA_FILL;
319
+ trans.getRow(7).getCell(3).value = {
320
+ formula: `=MAX(0,0.95-0.02)`,
321
+ result: 0.93,
322
+ };
323
+ trans.getRow(7).getCell(3).fill = FORMULA_FILL;
324
+ trans.getRow(7).getCell(4).value = 0.02;
325
+ // Row 8: Dead (Comparator) — static
226
326
  trans.getRow(8).values = ["Dead", 0, 0, 1];
327
+ // Apply number format to all data cells
227
328
  for (let r = 2; r <= 8; r++) {
228
329
  for (let c = 2; c <= 4; c++) {
229
330
  const cell = trans.getRow(r).getCell(c);
230
- if (typeof cell.value === "number") {
331
+ if (cell.value !== undefined && cell.value !== "") {
231
332
  cell.numFmt = "0.0000";
232
333
  }
233
334
  }
234
335
  }
235
336
  trans.getRow(10).getCell(1).value =
236
- "Transition probabilities derived from efficacy_delta and mortality_reduction inputs. Values are read-only for transparency modify efficacy_delta on the Inputs tab to change them.";
337
+ "Rows must sum to 1.0. Formulas reference Inputs tabedit efficacy_delta or mortality_reduction to update.";
237
338
  trans.getRow(10).getCell(1).font = {
238
339
  italic: true,
239
340
  color: { argb: "FF666666" },
240
341
  };
241
- trans.getRow(10).getCell(1).value =
242
- "Rows must sum to 1.0. Editable — modify to reflect trial-specific transitions.";
243
- trans.getRow(10).getCell(1).font = {
244
- italic: true,
245
- color: { argb: "FF666666" },
342
+ // --- Tab 4: Markov Trace (NEW — live formulas for state populations and discounted costs/QALYs) ---
343
+ const traceSheet = wb.addWorksheet("Markov Trace");
344
+ traceSheet.columns = [
345
+ { header: "Cycle", key: "cycle", width: 8 },
346
+ { header: "Int-On", key: "i_on", width: 12 },
347
+ { header: "Int-Off", key: "i_off", width: 12 },
348
+ { header: "Int-Dead", key: "i_dead", width: 12 },
349
+ { header: "Comp-On", key: "c_on", width: 12 },
350
+ { header: "Comp-Off", key: "c_off", width: 12 },
351
+ { header: "Comp-Dead", key: "c_dead", width: 12 },
352
+ { header: "Cost Disc Factor", key: "hdf", width: 18 },
353
+ { header: "Int Disc Cost", key: "idc", width: 18 },
354
+ { header: "Comp Disc Cost", key: "cdc", width: 18 },
355
+ { header: "QALY Disc Factor", key: "kdf", width: 18 },
356
+ { header: "Int Disc QALY", key: "idq", width: 18 },
357
+ { header: "Comp Disc QALY", key: "cdq", width: 18 },
358
+ ];
359
+ for (let c = 1; c <= 13; c++) {
360
+ styleHeaderCell(traceSheet.getRow(1).getCell(c));
361
+ }
362
+ // Row 2: Cycle 0 (initial state)
363
+ // Precompute initial discounted cost/QALY for result caching
364
+ const drugCost = params.cost_inputs.drug_cost_annual;
365
+ const comparatorCost = params.cost_inputs.comparator_cost_annual;
366
+ const adminCost = params.cost_inputs.admin_cost ?? 0;
367
+ const qalyOnTx = params.utility_inputs?.qaly_on_treatment ?? 0.75;
368
+ const qalyComparator = params.utility_inputs?.qaly_comparator ?? 0.7;
369
+ const initIntDiscCost = drugCost + adminCost; // cycle 0: 100% On-Treatment
370
+ const initCompDiscCost = comparatorCost + adminCost;
371
+ const initIntDiscQaly = qalyOnTx; // cycle 0: 100% On-Treatment
372
+ const initCompDiscQaly = qalyOnTx; // cycle 0: comparator also starts On-Treatment
373
+ traceSheet.getRow(2).getCell(1).value = 0; // cycle
374
+ traceSheet.getRow(2).getCell(2).value = 1; // Int-On = 1
375
+ traceSheet.getRow(2).getCell(3).value = 0; // Int-Off = 0
376
+ traceSheet.getRow(2).getCell(4).value = 0; // Int-Dead = 0
377
+ traceSheet.getRow(2).getCell(5).value = 1; // Comp-On = 1
378
+ traceSheet.getRow(2).getCell(6).value = 0; // Comp-Off = 0
379
+ traceSheet.getRow(2).getCell(7).value = 0; // Comp-Dead = 0
380
+ // H2: cost discount factor at cycle 0 = 1
381
+ traceSheet.getRow(2).getCell(8).value = { formula: `=1`, result: 1 };
382
+ traceSheet.getRow(2).getCell(8).fill = FORMULA_FILL;
383
+ // I2: Int discounted cost at cycle 0
384
+ traceSheet.getRow(2).getCell(9).value = {
385
+ formula: `=H2*(B2*(Inputs!$B$2+Inputs!$B$4))`,
386
+ result: initIntDiscCost,
387
+ };
388
+ traceSheet.getRow(2).getCell(9).fill = FORMULA_FILL;
389
+ // J2: Comp discounted cost at cycle 0
390
+ traceSheet.getRow(2).getCell(10).value = {
391
+ formula: `=H2*(E2*(Inputs!$B$3+Inputs!$B$4))`,
392
+ result: initCompDiscCost,
393
+ };
394
+ traceSheet.getRow(2).getCell(10).fill = FORMULA_FILL;
395
+ // K2: QALY discount factor at cycle 0 = 1
396
+ traceSheet.getRow(2).getCell(11).value = { formula: `=1`, result: 1 };
397
+ traceSheet.getRow(2).getCell(11).fill = FORMULA_FILL;
398
+ // L2: Int discounted QALY at cycle 0
399
+ traceSheet.getRow(2).getCell(12).value = {
400
+ formula: `=K2*(B2*Inputs!$B$8+C2*Inputs!$B$9)`,
401
+ result: initIntDiscQaly,
246
402
  };
247
- // --- Tab 4: PSA Iterations ---
403
+ traceSheet.getRow(2).getCell(12).fill = FORMULA_FILL;
404
+ // M2: Comp discounted QALY at cycle 0
405
+ traceSheet.getRow(2).getCell(13).value = {
406
+ formula: `=K2*(E2*Inputs!$B$8+F2*Inputs!$B$9)`,
407
+ result: initCompDiscQaly,
408
+ };
409
+ traceSheet.getRow(2).getCell(13).fill = FORMULA_FILL;
410
+ // Apply number formats to row 2
411
+ traceSheet.getRow(2).getCell(9).numFmt = `"${symbol}"#,##0`;
412
+ traceSheet.getRow(2).getCell(10).numFmt = `"${symbol}"#,##0`;
413
+ traceSheet.getRow(2).getCell(12).numFmt = "0.000";
414
+ traceSheet.getRow(2).getCell(13).numFmt = "0.000";
415
+ // Rows 3..n_cycles+2: one row per cycle 1..n_cycles
416
+ // Precompute Markov trace for result caching
417
+ let intOn = 1, intOff = 0, intDead = 0;
418
+ let compOn = 1, compOff = 0, compDead = 0;
419
+ let costDiscFactor = 1;
420
+ let qalyDiscFactor = 1;
421
+ const discRateCosts = 0.035;
422
+ const discRateOutcomes = 0.035;
423
+ // Transition probabilities (precomputed)
424
+ const pIntOnOn = probStayOnIntervention;
425
+ const pIntOffOn = 0.05;
426
+ const pIntOnOff = Math.max(0, 1 - probStayOnIntervention - interventionMortality);
427
+ const pIntOffOff = Math.max(0, 0.95 - interventionMortality);
428
+ const pIntOnDead = interventionMortality;
429
+ const pIntOffDead = interventionMortality;
430
+ const pCompOnOn = baselineProbStayOn;
431
+ const pCompOffOn = 0.05;
432
+ const pCompOnOff = Math.max(0, 1 - baselineProbStayOn - comparatorMortality);
433
+ const pCompOffOff = Math.max(0, 0.95 - comparatorMortality);
434
+ const pCompOnDead = comparatorMortality;
435
+ const pCompOffDead = comparatorMortality;
436
+ for (let cycle = 1; cycle <= n_cycles; cycle++) {
437
+ const r = cycle + 2; // Excel row number
438
+ const prev = r - 1;
439
+ // Apply transitions for caching
440
+ costDiscFactor = costDiscFactor * (1 / (1 + discRateCosts));
441
+ qalyDiscFactor = qalyDiscFactor * (1 / (1 + discRateOutcomes));
442
+ const newIntOn = pIntOnOn * intOn + pIntOffOn * intOff; // Dead stays dead → 0 contribution
443
+ const newIntOff = pIntOnOff * intOn + pIntOffOff * intOff;
444
+ const newIntDead = pIntOnDead * intOn + pIntOffDead * intOff + intDead;
445
+ intOn = newIntOn;
446
+ intOff = newIntOff;
447
+ intDead = newIntDead;
448
+ const newCompOn = pCompOnOn * compOn + pCompOffOn * compOff;
449
+ const newCompOff = pCompOnOff * compOn + pCompOffOff * compOff;
450
+ const newCompDead = pCompOnDead * compOn + pCompOffDead * compOff + compDead;
451
+ compOn = newCompOn;
452
+ compOff = newCompOff;
453
+ compDead = newCompDead;
454
+ const intDiscCost = costDiscFactor * (intOn * (drugCost + adminCost));
455
+ const compDiscCost = costDiscFactor * (compOn * (comparatorCost + adminCost));
456
+ const intDiscQaly = qalyDiscFactor * (intOn * qalyOnTx + intOff * qalyComparator);
457
+ const compDiscQaly = qalyDiscFactor * (compOn * qalyOnTx + compOff * qalyComparator);
458
+ const row = traceSheet.getRow(r);
459
+ // A: cycle number
460
+ row.getCell(1).value = cycle;
461
+ // B: Int-On
462
+ row.getCell(2).value = {
463
+ formula: `='Transition Matrix'!$B$2*B${prev}+'Transition Matrix'!$B$3*C${prev}+'Transition Matrix'!$B$4*D${prev}`,
464
+ result: intOn,
465
+ };
466
+ row.getCell(2).fill = FORMULA_FILL;
467
+ // C: Int-Off
468
+ row.getCell(3).value = {
469
+ formula: `='Transition Matrix'!$C$2*B${prev}+'Transition Matrix'!$C$3*C${prev}+'Transition Matrix'!$C$4*D${prev}`,
470
+ result: intOff,
471
+ };
472
+ row.getCell(3).fill = FORMULA_FILL;
473
+ // D: Int-Dead
474
+ row.getCell(4).value = {
475
+ formula: `='Transition Matrix'!$D$2*B${prev}+'Transition Matrix'!$D$3*C${prev}+'Transition Matrix'!$D$4*D${prev}`,
476
+ result: intDead,
477
+ };
478
+ row.getCell(4).fill = FORMULA_FILL;
479
+ // E: Comp-On
480
+ row.getCell(5).value = {
481
+ formula: `='Transition Matrix'!$B$6*E${prev}+'Transition Matrix'!$B$7*F${prev}+'Transition Matrix'!$B$8*G${prev}`,
482
+ result: compOn,
483
+ };
484
+ row.getCell(5).fill = FORMULA_FILL;
485
+ // F: Comp-Off
486
+ row.getCell(6).value = {
487
+ formula: `='Transition Matrix'!$C$6*E${prev}+'Transition Matrix'!$C$7*F${prev}+'Transition Matrix'!$C$8*G${prev}`,
488
+ result: compOff,
489
+ };
490
+ row.getCell(6).fill = FORMULA_FILL;
491
+ // G: Comp-Dead
492
+ row.getCell(7).value = {
493
+ formula: `='Transition Matrix'!$D$6*E${prev}+'Transition Matrix'!$D$7*F${prev}+'Transition Matrix'!$D$8*G${prev}`,
494
+ result: compDead,
495
+ };
496
+ row.getCell(7).fill = FORMULA_FILL;
497
+ // H: Cost discount factor (recurrence)
498
+ row.getCell(8).value = {
499
+ formula: `=H${prev}*(1/(1+Inputs!$B$10))`,
500
+ result: costDiscFactor,
501
+ };
502
+ row.getCell(8).fill = FORMULA_FILL;
503
+ // I: Int discounted cost
504
+ row.getCell(9).value = {
505
+ formula: `=H${r}*(B${r}*(Inputs!$B$2+Inputs!$B$4))`,
506
+ result: intDiscCost,
507
+ };
508
+ row.getCell(9).fill = FORMULA_FILL;
509
+ row.getCell(9).numFmt = `"${symbol}"#,##0`;
510
+ // J: Comp discounted cost
511
+ row.getCell(10).value = {
512
+ formula: `=H${r}*(E${r}*(Inputs!$B$3+Inputs!$B$4))`,
513
+ result: compDiscCost,
514
+ };
515
+ row.getCell(10).fill = FORMULA_FILL;
516
+ row.getCell(10).numFmt = `"${symbol}"#,##0`;
517
+ // K: QALY discount factor (recurrence)
518
+ row.getCell(11).value = {
519
+ formula: `=K${prev}*(1/(1+Inputs!$B$11))`,
520
+ result: qalyDiscFactor,
521
+ };
522
+ row.getCell(11).fill = FORMULA_FILL;
523
+ // L: Int discounted QALY
524
+ row.getCell(12).value = {
525
+ formula: `=K${r}*(B${r}*Inputs!$B$8+C${r}*Inputs!$B$9)`,
526
+ result: intDiscQaly,
527
+ };
528
+ row.getCell(12).fill = FORMULA_FILL;
529
+ row.getCell(12).numFmt = "0.000";
530
+ // M: Comp discounted QALY
531
+ row.getCell(13).value = {
532
+ formula: `=K${r}*(E${r}*Inputs!$B$8+F${r}*Inputs!$B$9)`,
533
+ result: compDiscQaly,
534
+ };
535
+ row.getCell(13).fill = FORMULA_FILL;
536
+ row.getCell(13).numFmt = "0.000";
537
+ }
538
+ // --- Tab 5: PSA Iterations ---
248
539
  if (result.psa && result.psa.scatter.length > 0) {
249
540
  const psaSheet = wb.addWorksheet("PSA");
250
541
  psaSheet.columns = [
@@ -291,7 +582,7 @@ export async function ceModelToXlsx(params, result, audit) {
291
582
  psaSheet.getRow(summaryRow + 1).getCell(1).font = { bold: true };
292
583
  psaSheet.getRow(summaryRow + 1).getCell(2).numFmt = `"${symbol}"#,##0`;
293
584
  }
294
- // --- Tab 5: CEAC ---
585
+ // --- Tab 6: CEAC (COUNTIF formulas referencing PSA sheet) ---
295
586
  if (result.psa && result.psa.ceac.length > 0) {
296
587
  const ceacSheet = wb.addWorksheet("CEAC");
297
588
  ceacSheet.columns = [
@@ -299,15 +590,21 @@ export async function ceModelToXlsx(params, result, audit) {
299
590
  { header: "P(cost-effective)", key: "p", width: 20 },
300
591
  ];
301
592
  ["A1", "B1"].forEach((c) => styleHeaderCell(ceacSheet.getCell(c)));
593
+ const n_psa = result.psa.scatter.length;
594
+ const lastPSARow = n_psa + 1;
302
595
  result.psa.ceac.forEach((pt, i) => {
303
596
  const row = ceacSheet.getRow(i + 2);
304
597
  row.getCell(1).value = pt.wtp;
305
- row.getCell(2).value = pt.prob_ce;
306
598
  row.getCell(1).numFmt = `"${symbol}"#,##0`;
599
+ row.getCell(2).value = {
600
+ formula: `=COUNTIFS(PSA!$D$2:PSA!$D$${lastPSARow},"<>N/A",PSA!$D$2:PSA!$D$${lastPSARow},"<="&A${i + 2})/${n_psa}`,
601
+ result: pt.prob_ce,
602
+ };
603
+ row.getCell(2).fill = FORMULA_FILL;
307
604
  row.getCell(2).numFmt = "0.00%";
308
605
  });
309
606
  }
310
- // --- Tab 6: Audit ---
607
+ // --- Tab 7: Audit ---
311
608
  const auditSheet = wb.addWorksheet("Audit");
312
609
  auditSheet.columns = [
313
610
  { header: "Field", key: "field", width: 25 },
@@ -340,6 +637,9 @@ export async function bimToXlsx(params, results, audit) {
340
637
  wb.created = new Date();
341
638
  const perspective = params.perspective ?? "nhs";
342
639
  const symbol = perspective === "nhs" ? "£" : "$";
640
+ const totalNet = results.reduce((s, r) => s + r.net_budget_impact, 0);
641
+ const totalTreated = results.reduce((s, r) => s + r.treated_population, 0);
642
+ const dataLastRow = results.length + 1; // last data row in Year-by-Year (1-based, row 2 = year 1)
343
643
  // --- Tab 1: Summary ---
344
644
  const summary = wb.addWorksheet("Summary");
345
645
  summary.columns = [
@@ -348,8 +648,6 @@ export async function bimToXlsx(params, results, audit) {
348
648
  ];
349
649
  styleHeaderCell(summary.getCell("A1"));
350
650
  styleHeaderCell(summary.getCell("B1"));
351
- const totalNet = results.reduce((s, r) => s + r.net_budget_impact, 0);
352
- const totalTreated = results.reduce((s, r) => s + r.treated_population, 0);
353
651
  [
354
652
  ["Intervention", params.intervention],
355
653
  ["Comparator", params.comparator],
@@ -358,21 +656,35 @@ export async function bimToXlsx(params, results, audit) {
358
656
  ["Time Horizon (years)", results.length],
359
657
  ["Eligible Population (Year 1)", params.eligible_population],
360
658
  ["", ""],
361
- ["Total Net Budget Impact", totalNet],
362
- ["Total Patients Treated", totalTreated],
363
- ["Average Net Cost per Patient", totalNet / (totalTreated || 1)],
364
659
  ].forEach((r, i) => {
365
660
  const row = summary.getRow(i + 2);
366
661
  row.getCell(1).value = r[0];
367
662
  row.getCell(2).value = r[1];
368
- const metric = r[0];
369
- if (metric.includes("Cost") || metric.includes("Impact")) {
370
- row.getCell(2).numFmt = `"${symbol}"#,##0`;
371
- }
372
- else if (metric.includes("Population") || metric.includes("Patients")) {
373
- row.getCell(2).numFmt = "#,##0";
374
- }
375
663
  });
664
+ // Row 9: Total Net Budget Impact — SUM formula referencing Year-by-Year
665
+ summary.getRow(9).getCell(1).value = "Total Net Budget Impact";
666
+ summary.getRow(9).getCell(2).value = {
667
+ formula: `=SUM('Year-by-Year'!H2:H${dataLastRow})`,
668
+ result: totalNet,
669
+ };
670
+ summary.getRow(9).getCell(2).numFmt = `"${symbol}"#,##0`;
671
+ summary.getRow(9).getCell(2).fill = FORMULA_FILL;
672
+ // Row 10: Total Patients Treated
673
+ summary.getRow(10).getCell(1).value = "Total Patients Treated";
674
+ summary.getRow(10).getCell(2).value = {
675
+ formula: `=SUM('Year-by-Year'!C2:C${dataLastRow})`,
676
+ result: totalTreated,
677
+ };
678
+ summary.getRow(10).getCell(2).numFmt = "#,##0";
679
+ summary.getRow(10).getCell(2).fill = FORMULA_FILL;
680
+ // Row 11: Average Net Cost per Patient
681
+ summary.getRow(11).getCell(1).value = "Average Net Cost per Patient";
682
+ summary.getRow(11).getCell(2).value = {
683
+ formula: `=IF(B10=0,0,B9/B10)`,
684
+ result: totalTreated > 0 ? totalNet / totalTreated : 0,
685
+ };
686
+ summary.getRow(11).getCell(2).numFmt = `"${symbol}"#,##0`;
687
+ summary.getRow(11).getCell(2).fill = FORMULA_FILL;
376
688
  // --- Tab 2: Inputs (editable) ---
377
689
  const inputs = wb.addWorksheet("Inputs");
378
690
  inputs.columns = [
@@ -482,11 +794,20 @@ export async function bimToXlsx(params, results, audit) {
482
794
  for (let c = 5; c <= 9; c++)
483
795
  row.getCell(c).numFmt = `"${symbol}"#,##0`;
484
796
  });
485
- // Total row
486
- const totalRow = yearly.getRow(results.length + 2);
797
+ // Total row — SUM formulas
798
+ const totalRowNum = results.length + 2;
799
+ const totalRow = yearly.getRow(totalRowNum);
487
800
  totalRow.getCell(1).value = "Total";
488
- totalRow.getCell(3).value = totalTreated;
489
- totalRow.getCell(8).value = totalNet;
801
+ totalRow.getCell(3).value = {
802
+ formula: `=SUM(C2:C${dataLastRow})`,
803
+ result: totalTreated,
804
+ };
805
+ totalRow.getCell(3).fill = FORMULA_FILL;
806
+ totalRow.getCell(8).value = {
807
+ formula: `=SUM(H2:H${dataLastRow})`,
808
+ result: totalNet,
809
+ };
810
+ totalRow.getCell(8).fill = FORMULA_FILL;
490
811
  totalRow.font = { bold: true };
491
812
  totalRow.getCell(3).numFmt = "#,##0";
492
813
  totalRow.getCell(8).numFmt = `"${symbol}"#,##0`;