Coverage for backend/django/Economics/results/services/chart_datasets.py: 88%

226 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-06-23 21:51 +0000

1"""Build v1 chart datasets from persisted Economics result rows. 

2 

3Charts are presentation summaries only. This service reads 

4``EconomicsResultLine`` rows produced by the result lifecycle service and 

5materializes compact ``EconomicsChartDataset`` rows for later API/export use. 

6It does not calculate new financial meaning; values, warning references, 

7assumptions, and drill-back row identifiers all come from the result table rows. 

8""" 

9 

10from __future__ import annotations 

11 

12from decimal import Decimal, InvalidOperation 

13from typing import Literal, TypeAlias 

14 

15from django.db import transaction 

16from pydantic import BaseModel, ConfigDict 

17 

18from Economics.results.models import EconomicsChartDataset, EconomicsResultLine, EconomicsResultRun 

19 

20from Economics.shared.choices import OperatingLineEconomicEffect, OperatingLineCategory, ResultLineKind 

21 

22 

23CHART_CASH_FLOW_NPV = "cash_flow_npv" 

24CHART_CAPEX_BREAKDOWN = "capex_breakdown" 

25CHART_OPEX_BREAKDOWN = "opex_breakdown" 

26CHART_MANUAL_BASELINE_COMPARISON = "manual_baseline_comparison" 

27V1_CHART_KEYS = ( 

28 CHART_CASH_FLOW_NPV, 

29 CHART_CAPEX_BREAKDOWN, 

30 CHART_OPEX_BREAKDOWN, 

31 CHART_MANUAL_BASELINE_COMPARISON, 

32) 

33COMPARISON_METRIC_KEYS = ( 

34 "capex", 

35 "annual_opex", 

36 "annual_savings", 

37 "npv", 

38 "simple_payback_years", 

39 "lcoh", 

40) 

41ChartScalar: TypeAlias = str | int | Decimal | bool | None 

42 

43 

44class EconomicsContract(BaseModel): 

45 model_config = ConfigDict(frozen=True) 

46 

47 

48class ChartWarningRef(EconomicsContract): 

49 code: str 

50 severity: str 

51 message: str 

52 source_row_key: str | None = None 

53 

54 

55class ChartSourceRow(EconomicsContract): 

56 id: int 

57 row_key: str 

58 label: str 

59 

60 

61class ChartAssumptionRecord(EconomicsContract): 

62 """Normalized tooltip assumption copied from result-line JSON boundaries.""" 

63 

64 key: str 

65 value: ChartScalar 

66 

67 

68class CashFlowPointMetadata(EconomicsContract): 

69 point_type: Literal["cash_flow"] 

70 year: int | None 

71 present_value: Decimal | None 

72 

73 

74class RankedBreakdownPointMetadata(EconomicsContract): 

75 point_type: Literal["ranked_breakdown"] 

76 rank: int 

77 group: str 

78 

79 

80class ComparisonPointMetadata(EconomicsContract): 

81 point_type: Literal["comparison"] 

82 category: str 

83 series_key: str 

84 

85 

86ChartPointMetadata: TypeAlias = CashFlowPointMetadata | RankedBreakdownPointMetadata | ComparisonPointMetadata 

87 

88 

89class ChartDatum(EconomicsContract): 

90 key: str 

91 label: str 

92 value: Decimal | None 

93 unit: str 

94 source_row: ChartSourceRow | None = None 

95 assumptions: tuple[ChartAssumptionRecord, ...] 

96 warning_refs: tuple[ChartWarningRef, ...] 

97 metadata: ChartPointMetadata 

98 

99 

100class ChartSeries(EconomicsContract): 

101 key: str 

102 label: str 

103 unit: str 

104 points: tuple[ChartDatum, ...] 

105 

106 

107class CashFlowRenderingMetadata(EconomicsContract): 

108 chart_family: Literal["cash_flow_npv"] 

109 x_axis: Literal["project_year"] 

110 zero_reference: Decimal 

111 payback_years: Decimal | None 

112 npv: Decimal | None 

113 warning_refs: tuple[ChartWarningRef, ...] 

114 

115 

116class RankedBreakdownRenderingMetadata(EconomicsContract): 

117 chart_family: Literal["ranked_breakdown"] 

118 ranking: Literal["amount_desc"] 

119 warning_refs: tuple[ChartWarningRef, ...] 

120 

121 

122class ComparisonRenderingMetadata(EconomicsContract): 

123 chart_family: Literal["manual_baseline_comparison"] 

124 categories: tuple[str, ...] 

125 series_keys: tuple[str, ...] 

126 warning_refs: tuple[ChartWarningRef, ...] 

127 

128 

129class ChartDataPayload(EconomicsContract): 

130 chart_key: str 

131 title: str 

132 chart_type: str 

133 series: tuple[ChartSeries, ...] 

134 

135 

136ChartRenderingMetadata: TypeAlias = CashFlowRenderingMetadata | RankedBreakdownRenderingMetadata | ComparisonRenderingMetadata 

137 

138 

139class ChartDatasetContract(EconomicsContract): 

140 chart_key: str 

141 title: str 

142 chart_type: str 

143 source_row_keys: tuple[str, ...] 

144 series: tuple[ChartSeries, ...] 

145 rendering_metadata: ChartRenderingMetadata 

146 

147 def chart_data_payload(self) -> ChartDataPayload: 

148 """Return the typed payload that is serialized into ``EconomicsChartDataset.chart_data``.""" 

149 return ChartDataPayload( 

150 chart_key=self.chart_key, 

151 title=self.title, 

152 chart_type=self.chart_type, 

153 series=self.series, 

154 ) 

155 

156 def rendering_metadata_payload(self) -> ChartRenderingMetadata: 

157 """Return typed compact chart configuration for the rendering metadata JSON boundary.""" 

158 return self.rendering_metadata 

159 

160 

161def build_chart_datasets(result_run: EconomicsResultRun) -> tuple[ChartDatasetContract, ...]: 

162 """Return deterministic v1 chart datasets derived from a result run's rows.""" 

163 lines = list( 

164 result_run.lines.select_related("source_capital_line", "source_operating_line").order_by( 

165 "sort_order", 

166 "created_at", 

167 "pk", 

168 ) 

169 ) 

170 line_by_row_key = {line.row_key: line for line in lines} 

171 return ( 

172 _cash_flow_npv_dataset(lines=lines, line_by_row_key=line_by_row_key), 

173 _ranked_breakdown_dataset( 

174 chart_key=CHART_CAPEX_BREAKDOWN, 

175 title="Capital Cost Breakdown", 

176 line_kind=ResultLineKind.CAPITAL, 

177 source_group="capital_lines", 

178 lines=lines, 

179 ), 

180 _ranked_breakdown_dataset( 

181 chart_key=CHART_OPEX_BREAKDOWN, 

182 title="Operating Cost Breakdown", 

183 line_kind=ResultLineKind.OPERATING, 

184 source_group="operating_lines", 

185 lines=lines, 

186 ), 

187 _manual_baseline_comparison_dataset(line_by_row_key=line_by_row_key), 

188 ) 

189 

190 

191def materialize_chart_datasets(result_run: EconomicsResultRun) -> tuple[ChartDatasetContract, ...]: 

192 """Upsert the required v1 chart datasets for ``result_run`` transactionally.""" 

193 datasets = build_chart_datasets(result_run) 

194 with transaction.atomic(): 

195 for dataset in datasets: 

196 EconomicsChartDataset.objects.update_or_create( 

197 flowsheet=result_run.flowsheet, 

198 result_run=result_run, 

199 chart_key=dataset.chart_key, 

200 defaults={ 

201 "title": dataset.title, 

202 "chart_type": dataset.chart_type, 

203 "source_row_keys": list(dataset.source_row_keys), 

204 "chart_data": dataset.chart_data_payload().model_dump(mode="json"), 

205 "rendering_metadata": dataset.rendering_metadata_payload().model_dump(mode="json"), 

206 }, 

207 ) 

208 return datasets 

209 

210 

211def _cash_flow_npv_dataset( 

212 *, 

213 lines: list[EconomicsResultLine], 

214 line_by_row_key: dict[str, EconomicsResultLine], 

215) -> ChartDatasetContract: 

216 cash_flow_lines = [line for line in lines if line.kind == ResultLineKind.CASH_FLOW] 

217 annual_points = [] 

218 cumulative_points = [] 

219 for line in cash_flow_lines: 

220 year = _year_from_cash_flow_key(line.row_key) 

221 metadata = CashFlowPointMetadata(point_type="cash_flow", year=year, present_value=line.amount) 

222 annual_points.append( 

223 _datum_from_line( 

224 line, 

225 key=f"{line.row_key}.cash_flow", 

226 value=_decimal_from_payload(line.warning_payload, "cash_flow", fallback=line.amount), 

227 metadata=metadata, 

228 ) 

229 ) 

230 cumulative_points.append( 

231 _datum_from_line( 

232 line, 

233 key=f"{line.row_key}.cumulative_discounted", 

234 value=_decimal_from_payload(line.warning_payload, "cumulative_present_value", fallback=line.amount), 

235 metadata=metadata, 

236 ) 

237 ) 

238 

239 payback_line = line_by_row_key.get("metric.simple_payback_years") 

240 npv_line = line_by_row_key.get("metric.npv") 

241 source_row_keys = _source_row_keys(cash_flow_lines + [line for line in (payback_line, npv_line) if line is not None]) 

242 return ChartDatasetContract( 

243 chart_key=CHART_CASH_FLOW_NPV, 

244 title="Cash Flow And NPV", 

245 chart_type="combined_bar_line", 

246 source_row_keys=source_row_keys, 

247 series=( 

248 ChartSeries( 

249 key="annual_net_cash_flow", 

250 label="Annual Net Cash Flow", 

251 unit=_first_unit(cash_flow_lines), 

252 points=tuple(annual_points), 

253 ), 

254 ChartSeries( 

255 key="cumulative_discounted_cash_flow", 

256 label="Cumulative Discounted Cash Flow", 

257 unit=_first_unit(cash_flow_lines), 

258 points=tuple(cumulative_points), 

259 ), 

260 ), 

261 rendering_metadata=CashFlowRenderingMetadata( 

262 chart_family="cash_flow_npv", 

263 x_axis="project_year", 

264 zero_reference=Decimal("0"), 

265 payback_years=payback_line.amount if payback_line and payback_line.amount is not None else None, 

266 npv=npv_line.amount if npv_line and npv_line.amount is not None else None, 

267 warning_refs=_run_warning_refs(lines), 

268 ), 

269 ) 

270 

271 

272def _ranked_breakdown_dataset( 

273 *, 

274 chart_key: str, 

275 title: str, 

276 line_kind: str, 

277 source_group: str, 

278 lines: list[EconomicsResultLine], 

279) -> ChartDatasetContract: 

280 breakdown_lines = [ 

281 line 

282 for line in lines 

283 if line.kind == line_kind and line.group == source_group and line.amount is not None 

284 ] 

285 if line_kind == ResultLineKind.OPERATING and source_group == "operating_lines": 

286 breakdown_lines = [ 

287 line 

288 for line in breakdown_lines 

289 if not _operating_line_is_revenue(line) 

290 ] 

291 breakdown_lines.sort(key=lambda line: (-abs(line.amount or Decimal("0")), line.label, line.row_key)) 

292 points = tuple( 

293 _datum_from_line( 

294 line, 

295 key=line.row_key, 

296 value=line.amount, 

297 metadata=RankedBreakdownPointMetadata(point_type="ranked_breakdown", rank=index, group=source_group), 

298 ) 

299 for index, line in enumerate(breakdown_lines, start=1) 

300 ) 

301 return ChartDatasetContract( 

302 chart_key=chart_key, 

303 title=title, 

304 chart_type="ranked_bar", 

305 source_row_keys=_source_row_keys(breakdown_lines), 

306 series=( 

307 ChartSeries( 

308 key="amount", 

309 label=title, 

310 unit=_first_unit(breakdown_lines), 

311 points=points, 

312 ), 

313 ), 

314 rendering_metadata=RankedBreakdownRenderingMetadata( 

315 chart_family="ranked_breakdown", 

316 ranking="amount_desc", 

317 warning_refs=_run_warning_refs(breakdown_lines), 

318 ), 

319 ) 

320 

321 

322def _manual_baseline_comparison_dataset( 

323 *, 

324 line_by_row_key: dict[str, EconomicsResultLine], 

325) -> ChartDatasetContract: 

326 points: list[ChartDatum] = [] 

327 capex_line = line_by_row_key.get("metric.capex") 

328 incremental_capex_line = line_by_row_key.get("metric.incremental_capex") 

329 if capex_line is not None: 

330 points.append(_comparison_datum(capex_line, series_key="target", category="capex", value=capex_line.amount)) 

331 baseline_capex = _baseline_capex(capex_line=capex_line, incremental_capex_line=incremental_capex_line) 

332 if incremental_capex_line is not None and baseline_capex is not None: 

333 points.append( 

334 _comparison_datum( 

335 incremental_capex_line, 

336 series_key="baseline", 

337 category="capex", 

338 value=baseline_capex, 

339 label="Baseline Capex", 

340 ) 

341 ) 

342 

343 opex_line = line_by_row_key.get("metric.annual_opex") 

344 annual_savings_line = line_by_row_key.get("metric.annual_savings") 

345 if opex_line is not None: 

346 points.append(_comparison_datum(opex_line, series_key="target", category="annual_opex", value=opex_line.amount)) 

347 baseline_opex = _decimal_from_assumptions(annual_savings_line, "baseline_annual_opex") 

348 if annual_savings_line is not None and baseline_opex is not None: 

349 points.append( 

350 _comparison_datum( 

351 annual_savings_line, 

352 series_key="baseline", 

353 category="annual_opex", 

354 value=baseline_opex, 

355 label="Baseline Annual Opex", 

356 ) 

357 ) 

358 

359 for metric_key in COMPARISON_METRIC_KEYS[2:]: 

360 line = line_by_row_key.get(f"metric.{metric_key}") 

361 if line is not None: 

362 points.append(_comparison_datum(line, series_key="result", category=metric_key, value=line.amount)) 

363 

364 source_rows = [point.source_row.row_key for point in points if point.source_row is not None] 

365 return ChartDatasetContract( 

366 chart_key=CHART_MANUAL_BASELINE_COMPARISON, 

367 title="Manual Baseline Comparison", 

368 chart_type="grouped_bar", 

369 source_row_keys=tuple(dict.fromkeys(source_rows)), 

370 series=( 

371 ChartSeries( 

372 key="comparison_values", 

373 label="Comparison Values", 

374 unit="mixed", 

375 points=tuple(points), 

376 ), 

377 ), 

378 rendering_metadata=ComparisonRenderingMetadata( 

379 chart_family="manual_baseline_comparison", 

380 categories=COMPARISON_METRIC_KEYS, 

381 series_keys=("target", "baseline", "result"), 

382 warning_refs=_run_warning_refs([line for line in line_by_row_key.values() if line.row_key in source_rows]), 

383 ), 

384 ) 

385 

386 

387def _comparison_datum( 

388 line: EconomicsResultLine, 

389 *, 

390 series_key: str, 

391 category: str, 

392 value: Decimal | None, 

393 label: str | None = None, 

394) -> ChartDatum: 

395 return _datum_from_line( 

396 line, 

397 key=f"{category}.{series_key}", 

398 label=label or line.label, 

399 value=value, 

400 metadata=ComparisonPointMetadata(point_type="comparison", category=category, series_key=series_key), 

401 ) 

402 

403 

404def _operating_category(line: EconomicsResultLine) -> str | None: 

405 payload = line.warning_payload 

406 if not isinstance(payload, dict): 

407 return None 

408 category = payload.get("category") 

409 return category if isinstance(category, str) else None 

410 

411 

412def _operating_line_is_revenue(line: EconomicsResultLine) -> bool: 

413 payload = line.warning_payload 

414 if not isinstance(payload, dict): 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

415 return False 

416 return ( 

417 payload.get("economic_effect") == OperatingLineEconomicEffect.REVENUE 

418 or payload.get("category") == OperatingLineCategory.OUTPUT_REVENUE 

419 ) 

420 

421 

422def _datum_from_line( 

423 line: EconomicsResultLine, 

424 *, 

425 key: str, 

426 value: Decimal | None, 

427 metadata: ChartPointMetadata, 

428 label: str | None = None, 

429) -> ChartDatum: 

430 return ChartDatum( 

431 key=key, 

432 label=label or line.source_label or line.label, 

433 value=value, 

434 unit=line.unit, 

435 source_row=ChartSourceRow(id=line.pk, row_key=line.row_key, label=line.label), 

436 assumptions=_assumption_records_for_line(line), 

437 warning_refs=tuple(_warning_refs_for_line(line)), 

438 metadata=metadata, 

439 ) 

440 

441 

442def _warning_refs_for_line(line: EconomicsResultLine) -> list[ChartWarningRef]: 

443 refs: list[ChartWarningRef] = [] 

444 payload = line.warning_payload if isinstance(line.warning_payload, dict) else {} 

445 warnings = payload.get("warnings", []) 

446 if not isinstance(warnings, list): 446 ↛ 447line 446 didn't jump to line 447 because the condition on line 446 was never true

447 warnings = [] 

448 for warning in warnings: 

449 if not isinstance(warning, dict): 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true

450 continue 

451 refs.append( 

452 ChartWarningRef( 

453 code=str(warning.get("code", "warning")), 

454 severity=str(warning.get("severity", "warning")), 

455 message=str(warning.get("message", "")), 

456 source_row_key=line.row_key, 

457 ) 

458 ) 

459 status = payload.get("status") 

460 if status and status != "calculated": 

461 refs.append( 

462 ChartWarningRef( 

463 code=f"metric_status_{status}", 

464 severity="warning", 

465 message=f"Metric status is {status}.", 

466 source_row_key=line.row_key, 

467 ) 

468 ) 

469 return refs 

470 

471 

472def _run_warning_refs(lines: list[EconomicsResultLine]) -> tuple[ChartWarningRef, ...]: 

473 refs: list[ChartWarningRef] = [] 

474 for line in lines: 

475 refs.extend(_warning_refs_for_line(line)) 

476 return tuple(refs) 

477 

478 

479def _assumption_records_for_line(line: EconomicsResultLine) -> tuple[ChartAssumptionRecord, ...]: 

480 """Normalize result-line assumption JSON into scalar tooltip records. 

481 

482 Financial metrics store assumptions as JSON at the result-line persistence 

483 boundary. Chart contracts expose those values as stable key/value records 

484 with a narrow scalar value union instead of preserving an arbitrary mapping. 

485 """ 

486 payload = line.warning_payload if isinstance(line.warning_payload, dict) else {} 

487 assumptions = payload.get("assumptions", {}) 

488 if not isinstance(assumptions, dict): 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 return () 

490 records = [] 

491 for key, value in sorted(assumptions.items()): 

492 records.append(ChartAssumptionRecord(key=str(key), value=_chart_scalar(value))) 

493 return tuple(records) 

494 

495 

496def _source_row_keys(lines: list[EconomicsResultLine]) -> tuple[str, ...]: 

497 return tuple(dict.fromkeys(line.row_key for line in lines if line is not None)) 

498 

499 

500def _first_unit(lines: list[EconomicsResultLine]) -> str: 

501 return next((line.unit for line in lines if line.unit), "") 

502 

503 

504def _year_from_cash_flow_key(row_key: str) -> int | None: 

505 try: 

506 return int(row_key.rsplit("_", maxsplit=1)[1]) 

507 except (IndexError, ValueError): 

508 return None 

509 

510 

511def _baseline_capex( 

512 *, 

513 capex_line: EconomicsResultLine | None, 

514 incremental_capex_line: EconomicsResultLine | None, 

515) -> Decimal | None: 

516 explicit_baseline = _decimal_from_assumptions(incremental_capex_line, "baseline_capex") 

517 if explicit_baseline is not None: 

518 return explicit_baseline 

519 if capex_line is None or capex_line.amount is None or incremental_capex_line is None or incremental_capex_line.amount is None: 519 ↛ 521line 519 didn't jump to line 521 because the condition on line 519 was always true

520 return None 

521 return capex_line.amount - incremental_capex_line.amount 

522 

523 

524def _decimal_from_assumptions(line: EconomicsResultLine | None, key: str) -> Decimal | None: 

525 if line is None: 

526 return None 

527 assumptions = line.warning_payload.get("assumptions", {}) 

528 if not isinstance(assumptions, dict): 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true

529 return None 

530 return _to_decimal(assumptions.get(key)) 

531 

532 

533def _decimal_from_payload(payload: object, key: str, *, fallback: Decimal | None) -> Decimal | None: 

534 if not isinstance(payload, dict): 534 ↛ 535line 534 didn't jump to line 535 because the condition on line 534 was never true

535 return fallback 

536 value = _to_decimal(payload.get(key)) 

537 return value if value is not None else fallback 

538 

539 

540def _to_decimal(value: object) -> Decimal | None: 

541 if value is None: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true

542 return None 

543 try: 

544 return Decimal(str(value)) 

545 except (InvalidOperation, ValueError): 

546 return None 

547 

548 

549def _decimal_string(value: Decimal | None) -> str | None: 

550 return str(value) if value is not None else None 

551 

552 

553def _chart_scalar(value: object) -> ChartScalar: 

554 if value is None or isinstance(value, str | int | bool | Decimal): 554 ↛ 556line 554 didn't jump to line 556 because the condition on line 554 was always true

555 return value 

556 if isinstance(value, float): 

557 return Decimal(str(value)) 

558 return str(value)