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
« 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.
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"""
10from __future__ import annotations
12from decimal import Decimal, InvalidOperation
13from typing import Literal, TypeAlias
15from django.db import transaction
16from pydantic import BaseModel, ConfigDict
18from Economics.results.models import EconomicsChartDataset, EconomicsResultLine, EconomicsResultRun
20from Economics.shared.choices import OperatingLineEconomicEffect, OperatingLineCategory, ResultLineKind
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
44class EconomicsContract(BaseModel):
45 model_config = ConfigDict(frozen=True)
48class ChartWarningRef(EconomicsContract):
49 code: str
50 severity: str
51 message: str
52 source_row_key: str | None = None
55class ChartSourceRow(EconomicsContract):
56 id: int
57 row_key: str
58 label: str
61class ChartAssumptionRecord(EconomicsContract):
62 """Normalized tooltip assumption copied from result-line JSON boundaries."""
64 key: str
65 value: ChartScalar
68class CashFlowPointMetadata(EconomicsContract):
69 point_type: Literal["cash_flow"]
70 year: int | None
71 present_value: Decimal | None
74class RankedBreakdownPointMetadata(EconomicsContract):
75 point_type: Literal["ranked_breakdown"]
76 rank: int
77 group: str
80class ComparisonPointMetadata(EconomicsContract):
81 point_type: Literal["comparison"]
82 category: str
83 series_key: str
86ChartPointMetadata: TypeAlias = CashFlowPointMetadata | RankedBreakdownPointMetadata | ComparisonPointMetadata
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
100class ChartSeries(EconomicsContract):
101 key: str
102 label: str
103 unit: str
104 points: tuple[ChartDatum, ...]
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, ...]
116class RankedBreakdownRenderingMetadata(EconomicsContract):
117 chart_family: Literal["ranked_breakdown"]
118 ranking: Literal["amount_desc"]
119 warning_refs: tuple[ChartWarningRef, ...]
122class ComparisonRenderingMetadata(EconomicsContract):
123 chart_family: Literal["manual_baseline_comparison"]
124 categories: tuple[str, ...]
125 series_keys: tuple[str, ...]
126 warning_refs: tuple[ChartWarningRef, ...]
129class ChartDataPayload(EconomicsContract):
130 chart_key: str
131 title: str
132 chart_type: str
133 series: tuple[ChartSeries, ...]
136ChartRenderingMetadata: TypeAlias = CashFlowRenderingMetadata | RankedBreakdownRenderingMetadata | ComparisonRenderingMetadata
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
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 )
156 def rendering_metadata_payload(self) -> ChartRenderingMetadata:
157 """Return typed compact chart configuration for the rendering metadata JSON boundary."""
158 return self.rendering_metadata
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 )
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
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 )
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 )
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 )
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 )
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 )
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))
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 )
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 )
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
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 )
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 )
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
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)
479def _assumption_records_for_line(line: EconomicsResultLine) -> tuple[ChartAssumptionRecord, ...]:
480 """Normalize result-line assumption JSON into scalar tooltip records.
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)
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))
500def _first_unit(lines: list[EconomicsResultLine]) -> str:
501 return next((line.unit for line in lines if line.unit), "")
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
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
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))
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
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
549def _decimal_string(value: Decimal | None) -> str | None:
550 return str(value) if value is not None else None
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)