Coverage for backend/django/Economics/results/services/comparison/charts.py: 97%

147 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1"""Build comparison-chart payloads from persisted result chart datasets.""" 

2 

3from __future__ import annotations 

4 

5from copy import deepcopy 

6from typing import Any 

7 

8from Economics.results.models import EconomicsChartDataset 

9from Economics.results.services.chart_datasets import ( 

10 CHART_CAPEX_BREAKDOWN, 

11 CHART_CASH_FLOW_NPV, 

12 CHART_OPEX_BREAKDOWN, 

13 CHART_OPERATING_COST_CUMULATIVE, 

14 CHART_OPERATING_COST_PROFILE, 

15) 

16 

17from .contracts import ( 

18 ComparisonChartDatasetPayload, 

19 ComparisonTarget, 

20 JsonValue, 

21 ResultContext, 

22) 

23 

24 

25COMPARISON_CHART_CASH_FLOW = "comparison_cash_flow_npv" 

26COMPARISON_CHART_COST_BREAKDOWN = "comparison_cost_breakdown" 

27COMPARISON_CHART_OPERATING_CUMULATIVE = "comparison_operating_cost_cumulative" 

28COMPARISON_CHART_OPERATING_PROFILE = "comparison_operating_cost_profile" 

29 

30_CASH_FLOW_METRIC_LABELS = { 

31 "cumulative_discounted_cash_flow": "Cumulative discounted cash flow", 

32 "annual_net_cash_flow": "Annual net cash flow", 

33} 

34_COST_BREAKDOWN_METRIC_LABELS = { 

35 CHART_CAPEX_BREAKDOWN: "Capital cost", 

36 CHART_OPEX_BREAKDOWN: "Operating cost", 

37} 

38_SOURCE_CHART_KEYS = ( 

39 CHART_CASH_FLOW_NPV, 

40 CHART_CAPEX_BREAKDOWN, 

41 CHART_OPEX_BREAKDOWN, 

42 CHART_OPERATING_COST_CUMULATIVE, 

43 CHART_OPERATING_COST_PROFILE, 

44) 

45 

46 

47def comparison_chart_datasets( 

48 *, 

49 studies: list[ComparisonTarget], 

50 result_contexts: dict[int | None, ResultContext | None], 

51) -> list[ComparisonChartDatasetPayload]: 

52 """Return comparison-overlay chart datasets using each study's current chart rows.""" 

53 datasets_by_run = _datasets_by_run(result_contexts) 

54 chart_datasets = [ 

55 _cash_flow_overlay_dataset( 

56 studies=studies, 

57 result_contexts=result_contexts, 

58 datasets_by_run=datasets_by_run, 

59 ), 

60 _cost_breakdown_dataset( 

61 studies=studies, 

62 result_contexts=result_contexts, 

63 datasets_by_run=datasets_by_run, 

64 ), 

65 _operating_timeline_overlay_dataset( 

66 studies=studies, 

67 result_contexts=result_contexts, 

68 datasets_by_run=datasets_by_run, 

69 source_chart_key=CHART_OPERATING_COST_CUMULATIVE, 

70 comparison_chart_key=COMPARISON_CHART_OPERATING_CUMULATIVE, 

71 title="Cumulative Operating Cost", 

72 dataset_id=-3, 

73 ), 

74 _operating_timeline_overlay_dataset( 

75 studies=studies, 

76 result_contexts=result_contexts, 

77 datasets_by_run=datasets_by_run, 

78 source_chart_key=CHART_OPERATING_COST_PROFILE, 

79 comparison_chart_key=COMPARISON_CHART_OPERATING_PROFILE, 

80 title="Operating Cost Profile", 

81 dataset_id=-4, 

82 ), 

83 ] 

84 return [dataset for dataset in chart_datasets if dataset is not None] 

85 

86 

87def _datasets_by_run( 

88 result_contexts: dict[int | None, ResultContext | None], 

89) -> dict[int, dict[str, EconomicsChartDataset]]: 

90 run_ids = [ 

91 context.run.pk 

92 for context in result_contexts.values() 

93 if context is not None and context.run.pk is not None 

94 ] 

95 datasets: dict[int, dict[str, EconomicsChartDataset]] = {} 

96 if not run_ids: 

97 return datasets 

98 # Result contexts are loaded per study only after access to that study's 

99 # flowsheet is checked. Use the base manager here so chart rows from 

100 # selected studies in other authorized flowsheets are not filtered out by 

101 # the active request flowsheet context. 

102 for dataset in EconomicsChartDataset._base_manager.filter( 

103 result_run_id__in=run_ids, 

104 chart_key__in=_SOURCE_CHART_KEYS, 

105 ).order_by("result_run_id", "chart_key"): 

106 datasets.setdefault(dataset.result_run_id, {})[dataset.chart_key] = dataset 

107 return datasets 

108 

109 

110def _cash_flow_overlay_dataset( 

111 *, 

112 studies: list[ComparisonTarget], 

113 result_contexts: dict[int | None, ResultContext | None], 

114 datasets_by_run: dict[int, dict[str, EconomicsChartDataset]], 

115) -> ComparisonChartDatasetPayload | None: 

116 series = [] 

117 source_row_keys: list[str] = [] 

118 warning_refs: list[dict[str, JsonValue]] = [] 

119 target_labels = _target_labels(studies) 

120 for target in studies: 

121 context = result_contexts.get(target.study_id) 

122 recalculated_series = _cash_flow_series_from_context( 

123 target=target, 

124 context=context, 

125 target_label=target_labels[target.study_id], 

126 ) 

127 if recalculated_series: 

128 series.extend(recalculated_series) 

129 source_row_keys.extend( 

130 f"cash_flow.year_{row.year}" 

131 for row in (context.discounted_cash_flow if context is not None else ()) 

132 ) 

133 continue 

134 dataset = _source_dataset( 

135 context=context, 

136 datasets_by_run=datasets_by_run, 

137 chart_key=CHART_CASH_FLOW_NPV, 

138 ) 

139 if dataset is None: 

140 continue 

141 source_row_keys.extend(_source_row_keys(dataset)) 

142 warning_refs.extend(_warning_refs(dataset)) 

143 for source_series in _series(dataset): 

144 metric_key = str(source_series.get("key", "")) 

145 if metric_key not in _CASH_FLOW_METRIC_LABELS: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 continue 

147 points = [ 

148 _comparison_point( 

149 point, 

150 target=target, 

151 metric_key=metric_key, 

152 metric_label=_CASH_FLOW_METRIC_LABELS[metric_key], 

153 source_chart_key=CHART_CASH_FLOW_NPV, 

154 ) 

155 for point in _points(source_series) 

156 ] 

157 if not points: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 continue 

159 metric_label = _CASH_FLOW_METRIC_LABELS[metric_key] 

160 series.append( 

161 { 

162 "key": f"{metric_key}.{target.study_id}", 

163 "label": f"{target_labels[target.study_id]} - {metric_label}", 

164 "unit": str(source_series.get("unit", "")), 

165 "points": points, 

166 } 

167 ) 

168 if not series: 

169 return None 

170 return ComparisonChartDatasetPayload( 

171 id=-1, 

172 chart_key=COMPARISON_CHART_CASH_FLOW, 

173 title="Cash Flow Overlay", 

174 chart_type="multi_line", 

175 source_row_keys=_unique(source_row_keys), 

176 chart_data={"series": series}, 

177 rendering_metadata={ 

178 "chart_family": COMPARISON_CHART_CASH_FLOW, 

179 "x_axis": "project_year", 

180 "metric_options": [ 

181 {"value": key, "label": label} 

182 for key, label in _CASH_FLOW_METRIC_LABELS.items() 

183 ], 

184 "default_metric_keys": ["cumulative_discounted_cash_flow"], 

185 "warning_refs": _unique_warning_refs(warning_refs), 

186 }, 

187 ) 

188 

189 

190def _cash_flow_series_from_context( 

191 *, 

192 target: ComparisonTarget, 

193 context: ResultContext | None, 

194 target_label: str, 

195) -> list[dict[str, JsonValue]]: 

196 """Build comparison cash-flow series from transient comparison recalculation rows.""" 

197 if context is None or not context.discounted_cash_flow: 

198 return [] 

199 

200 return [ 

201 { 

202 "key": f"{metric_key}.{target.study_id}", 

203 "label": f"{target_label} - {metric_label}", 

204 "unit": context.run.result_currency, 

205 "points": [ 

206 { 

207 "key": f"{target.study_id}.{metric_key}.cash_flow.year_{row.year}", 

208 "label": f"Year {row.year}", 

209 "value": _cash_flow_metric_value(row, metric_key), 

210 "unit": context.run.result_currency, 

211 "source_row": { 

212 "id": 0, 

213 "row_key": f"cash_flow.year_{row.year}", 

214 "label": f"Year {row.year}", 

215 }, 

216 "assumptions": [], 

217 "warning_refs": [], 

218 "metadata": { 

219 "point_type": "cash_flow", 

220 "year": row.year, 

221 "present_value": str(row.present_value), 

222 "study_id": target.study_id, 

223 "study_name": target.name, 

224 "flowsheet_name": target.flowsheet_name, 

225 "metric_key": metric_key, 

226 "metric_label": metric_label, 

227 "source_chart_key": CHART_CASH_FLOW_NPV, 

228 }, 

229 } 

230 for row in context.discounted_cash_flow 

231 ], 

232 } 

233 for metric_key, metric_label in _CASH_FLOW_METRIC_LABELS.items() 

234 ] 

235 

236 

237def _cash_flow_metric_value(row, metric_key: str) -> str: 

238 """Return a JSON-safe cash-flow chart value from a discounted cash-flow row.""" 

239 if metric_key == "annual_net_cash_flow": 

240 return str(row.cash_flow) 

241 return str(row.cumulative_present_value) 

242 

243 

244def _cost_breakdown_dataset( 

245 *, 

246 studies: list[ComparisonTarget], 

247 result_contexts: dict[int | None, ResultContext | None], 

248 datasets_by_run: dict[int, dict[str, EconomicsChartDataset]], 

249) -> ComparisonChartDatasetPayload | None: 

250 series = [] 

251 source_row_keys: list[str] = [] 

252 warning_refs: list[dict[str, JsonValue]] = [] 

253 target_labels = _target_labels(studies) 

254 for target in studies: 

255 context = result_contexts.get(target.study_id) 

256 points = [] 

257 for chart_key, metric_label in _COST_BREAKDOWN_METRIC_LABELS.items(): 

258 dataset = _source_dataset( 

259 context=context, 

260 datasets_by_run=datasets_by_run, 

261 chart_key=chart_key, 

262 ) 

263 if dataset is None: 

264 continue 

265 source_row_keys.extend(_source_row_keys(dataset)) 

266 warning_refs.extend(_warning_refs(dataset)) 

267 for source_series in _series(dataset): 

268 for point in _points(source_series): 

269 copied = _comparison_point( 

270 point, 

271 target=target, 

272 metric_key=chart_key, 

273 metric_label=metric_label, 

274 source_chart_key=chart_key, 

275 ) 

276 metadata = _metadata(copied) 

277 label = str(copied.get("label", "")) 

278 metadata["category_key"] = str( 

279 copied.get("source_row", {}).get("row_key") 

280 if isinstance(copied.get("source_row"), dict) 

281 else copied.get("key", label) 

282 ) 

283 metadata["category_label"] = f"{metric_label}: {label}" 

284 copied["metadata"] = metadata 

285 points.append(copied) 

286 if points: 

287 series.append( 

288 { 

289 "key": str(target.study_id), 

290 "label": target_labels[target.study_id], 

291 "unit": "mixed", 

292 "points": points, 

293 } 

294 ) 

295 if not series: 

296 return None 

297 return ComparisonChartDatasetPayload( 

298 id=-2, 

299 chart_key=COMPARISON_CHART_COST_BREAKDOWN, 

300 title="Cost Breakdown", 

301 chart_type="grouped_bar", 

302 source_row_keys=_unique(source_row_keys), 

303 chart_data={"series": series}, 

304 rendering_metadata={ 

305 "chart_family": COMPARISON_CHART_COST_BREAKDOWN, 

306 "metric_options": [ 

307 {"value": key, "label": label} 

308 for key, label in _COST_BREAKDOWN_METRIC_LABELS.items() 

309 ], 

310 "default_metric_keys": [ 

311 CHART_CAPEX_BREAKDOWN, 

312 CHART_OPEX_BREAKDOWN, 

313 ], 

314 "warning_refs": _unique_warning_refs(warning_refs), 

315 }, 

316 ) 

317 

318 

319def _operating_timeline_overlay_dataset( 

320 *, 

321 studies: list[ComparisonTarget], 

322 result_contexts: dict[int | None, ResultContext | None], 

323 datasets_by_run: dict[int, dict[str, EconomicsChartDataset]], 

324 source_chart_key: str, 

325 comparison_chart_key: str, 

326 title: str, 

327 dataset_id: int, 

328) -> ComparisonChartDatasetPayload | None: 

329 """Overlay one operating-cost timeline dataset for selected studies.""" 

330 series = [] 

331 source_row_keys: list[str] = [] 

332 warning_refs: list[dict[str, JsonValue]] = [] 

333 target_labels = _target_labels(studies) 

334 for target in studies: 

335 context = result_contexts.get(target.study_id) 

336 dataset = _source_dataset( 

337 context=context, 

338 datasets_by_run=datasets_by_run, 

339 chart_key=source_chart_key, 

340 ) 

341 if dataset is None: 

342 continue 

343 source_row_keys.extend(_source_row_keys(dataset)) 

344 warning_refs.extend(_warning_refs(dataset)) 

345 for source_series in _series(dataset): 345 ↛ 334line 345 didn't jump to line 334 because the loop on line 345 didn't complete

346 points = [ 

347 _comparison_point( 

348 point, 

349 target=target, 

350 metric_key=str(target.study_id), 

351 metric_label=target_labels[target.study_id], 

352 source_chart_key=source_chart_key, 

353 ) 

354 for point in _points(source_series) 

355 ] 

356 if points: 356 ↛ 345line 356 didn't jump to line 345 because the condition on line 356 was always true

357 series.append( 

358 { 

359 "key": str(target.study_id), 

360 "label": target_labels[target.study_id], 

361 "unit": str(source_series.get("unit", "")), 

362 "points": points, 

363 } 

364 ) 

365 break 

366 if not series: 

367 return None 

368 return ComparisonChartDatasetPayload( 

369 id=dataset_id, 

370 chart_key=comparison_chart_key, 

371 title=title, 

372 chart_type="multi_line", 

373 source_row_keys=_unique(source_row_keys), 

374 chart_data={"series": series}, 

375 rendering_metadata={ 

376 "chart_family": comparison_chart_key, 

377 "x_axis": "operating_hours", 

378 "metric_options": [ 

379 {"value": item["key"], "label": item["label"]} for item in series 

380 ], 

381 "default_metric_keys": [item["key"] for item in series], 

382 "warning_refs": _unique_warning_refs(warning_refs), 

383 }, 

384 ) 

385 

386 

387def _source_dataset( 

388 *, 

389 context: ResultContext | None, 

390 datasets_by_run: dict[int, dict[str, EconomicsChartDataset]], 

391 chart_key: str, 

392) -> EconomicsChartDataset | None: 

393 if context is None: 

394 return None 

395 return datasets_by_run.get(context.run.pk, {}).get(chart_key) 

396 

397 

398def _comparison_point( 

399 point: dict[str, Any], 

400 *, 

401 target: ComparisonTarget, 

402 metric_key: str, 

403 metric_label: str, 

404 source_chart_key: str, 

405) -> dict[str, JsonValue]: 

406 copied = deepcopy(point) 

407 metadata = _metadata(copied) 

408 metadata.update( 

409 { 

410 "study_id": target.study_id, 

411 "study_name": target.name, 

412 "flowsheet_name": target.flowsheet_name, 

413 "metric_key": metric_key, 

414 "metric_label": metric_label, 

415 "source_chart_key": source_chart_key, 

416 } 

417 ) 

418 copied["key"] = f"{target.study_id}.{metric_key}.{copied.get('key', '')}" 

419 copied["metadata"] = metadata 

420 return copied 

421 

422 

423def _target_labels(studies: list[ComparisonTarget]) -> dict[int | None, str]: 

424 """Use flowsheet names when repeated study names would make chart legends ambiguous.""" 

425 names = [target.name for target in studies] 

426 duplicate_names = len(set(names)) < len(names) 

427 return { 

428 target.study_id: ( 

429 (target.flowsheet_name or target.name) if duplicate_names else target.name 

430 ) 

431 for target in studies 

432 } 

433 

434 

435def _series(dataset: EconomicsChartDataset) -> list[dict[str, Any]]: 

436 chart_data = dataset.chart_data if isinstance(dataset.chart_data, dict) else {} 

437 series = chart_data.get("series", []) 

438 return [item for item in series if isinstance(item, dict)] 

439 

440 

441def _points(series: dict[str, Any]) -> list[dict[str, Any]]: 

442 points = series.get("points", []) 

443 return [item for item in points if isinstance(item, dict)] 

444 

445 

446def _metadata(point: dict[str, Any]) -> dict[str, JsonValue]: 

447 metadata = point.get("metadata", {}) 

448 return dict(metadata) if isinstance(metadata, dict) else {} 

449 

450 

451def _source_row_keys(dataset: EconomicsChartDataset) -> list[str]: 

452 keys = dataset.source_row_keys if isinstance(dataset.source_row_keys, list) else [] 

453 return [str(key) for key in keys] 

454 

455 

456def _warning_refs(dataset: EconomicsChartDataset) -> list[dict[str, JsonValue]]: 

457 metadata = dataset.rendering_metadata if isinstance(dataset.rendering_metadata, dict) else {} 

458 warning_refs = metadata.get("warning_refs", []) 

459 return [item for item in warning_refs if isinstance(item, dict)] 

460 

461 

462def _unique(values: list[str]) -> list[str]: 

463 return list(dict.fromkeys(values)) 

464 

465 

466def _unique_warning_refs( 

467 warning_refs: list[dict[str, JsonValue]], 

468) -> list[dict[str, JsonValue]]: 

469 keyed = { 

470 ( 

471 str(warning.get("code", "")), 

472 str(warning.get("message", "")), 

473 str(warning.get("source_row_key", "")), 

474 ): warning 

475 for warning in warning_refs 

476 } 

477 return list(keyed.values())