Coverage for backend/django/Economics/results/services/lifecycle/lifecycle.py: 100%
67 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"""Public orchestration facade for Economics presentation result lifecycle.
3This module coordinates synchronous Django-side result recalculation and reuse.
4It intentionally delegates fingerprinting, generated capital-line sync, status
5transitions, and result-line materialization to sibling modules so each lifecycle
6contract can evolve independently while the public import path remains stable.
7"""
9from __future__ import annotations
11import time
13from django.db import transaction
14from django.utils import timezone
16from Economics.results.models import EconomicsResultRun
18from Economics.studies.models import EconomicsStudy
20from Economics.shared.choices import ResultRunStatus
21from Economics.results.services.chart_datasets import materialize_chart_datasets
22from Economics.results.services.financial_metrics import calculate_study_financial_metrics
23from Economics.costing.capital.custom_capital_lines import sync_custom_capital_lines
24from Economics.formulas.native_properties.sync import sync_economics_native_properties_for_study
25from Economics.costing.operating.stream_properties import sync_operating_line_sources_for_study
26from Economics.results.services.lifecycle.common import duration_ms, get_assumptions, warning_payload
27from Economics.results.services.lifecycle.fingerprints import (
28 FINGERPRINT_ALGORITHM,
29 build_dependency_fingerprints,
30 _create_dependencies,
31)
32from Economics.costing.capital.generated_lines import _sync_generated_capital_lines
33from Economics.results.services.lifecycle.result_lines import (
34 _create_capital_result_lines,
35 _create_cash_flow_lines,
36 _create_depreciation_result_lines,
37 _create_metric_lines,
38 _create_operating_result_lines,
39)
40from Economics.results.services.lifecycle.runs import (
41 _annotate_reused_run,
42 _mark_nonmatching_current_runs_stale,
43 _matching_result_run,
44 mark_result_runs_stale_for_study,
45)
48def recalculate_presentation_results(
49 study: EconomicsStudy,
50 *,
51 reason: str = "explicit_recalculate",
52) -> EconomicsResultRun:
53 """Reuse or synchronously calculate a current presentation result run for a study.
55 Generated capital lines are synced before fingerprinting so matching logic
56 observes the persisted rows that will later become result-line details.
57 Reused runs still have detail rows rebuilt before chart materialization so
58 older matching runs receive any newer presentation rows without changing the
59 dependency contract.
60 """
61 started_at = time.perf_counter()
62 with transaction.atomic():
63 # Generated capital lines are dependency sources, so they must be
64 # materialized before the fingerprint set is built.
65 sync_operating_line_sources_for_study(study)
66 _sync_generated_capital_lines(study)
67 sync_custom_capital_lines(study)
68 fingerprints = build_dependency_fingerprints(study)
69 reusable_run = _matching_result_run(study=study, fingerprints=fingerprints)
70 if reusable_run is not None:
71 calculation = calculate_study_financial_metrics(study)
72 if reusable_run.status != ResultRunStatus.CURRENT:
73 reusable_run.status = ResultRunStatus.CURRENT
74 reusable_run.save(update_fields=["status"])
75 _mark_nonmatching_current_runs_stale(study=study, fingerprints=fingerprints, reason=reason)
76 reusable_run.warning_payload = _result_warning_payload(
77 calculation=calculation,
78 fingerprints=fingerprints,
79 reason=reason,
80 duration_ms=duration_ms(started_at),
81 )
82 reusable_run.completed_at = timezone.now()
83 reusable_run.save(update_fields=["warning_payload", "completed_at"])
84 # Matching historical runs can predate newer presentation rows, so
85 # detail rows are refreshed before charts are materialized.
86 _create_capital_result_lines(result_run=reusable_run)
87 _create_depreciation_result_lines(result_run=reusable_run)
88 _create_operating_result_lines(result_run=reusable_run)
89 _create_metric_lines(result_run=reusable_run, metrics=calculation.metrics)
90 _create_cash_flow_lines(result_run=reusable_run, rows=calculation.discounted_cash_flow)
91 materialize_chart_datasets(reusable_run)
92 sync_economics_native_properties_for_study(study)
93 _annotate_reused_run(result_run=reusable_run, reason=reason, duration_ms=duration_ms(started_at))
94 return reusable_run
96 calculation = calculate_study_financial_metrics(study)
97 _mark_nonmatching_current_runs_stale(study=study, fingerprints=fingerprints, reason=reason)
98 assumptions = get_assumptions(study)
99 run = EconomicsResultRun.objects.create(
100 flowsheet=study.flowsheet,
101 study=study,
102 status=ResultRunStatus.CURRENT,
103 result_currency=assumptions.currency if assumptions is not None else "NZD",
104 result_basis_date=assumptions.basis_date if assumptions is not None else None,
105 completed_at=timezone.now(),
106 warning_payload=_result_warning_payload(
107 calculation=calculation,
108 fingerprints=fingerprints,
109 reason=reason,
110 duration_ms=duration_ms(started_at),
111 ),
112 )
113 _create_dependencies(result_run=run, fingerprints=fingerprints)
114 _create_capital_result_lines(result_run=run)
115 _create_depreciation_result_lines(result_run=run)
116 _create_operating_result_lines(result_run=run)
117 _create_metric_lines(result_run=run, metrics=calculation.metrics)
118 _create_cash_flow_lines(result_run=run, rows=calculation.discounted_cash_flow)
119 materialize_chart_datasets(run)
120 sync_economics_native_properties_for_study(study)
121 return run
124def _result_warning_payload(
125 *,
126 calculation,
127 fingerprints,
128 reason: str,
129 duration_ms: int,
130) -> dict:
131 return {
132 "reason": reason,
133 "dependency_count": len(fingerprints),
134 "warnings": [warning_payload(warning) for warning in calculation.warnings],
135 "baseline": {
136 "source": calculation.baseline_resolution.source,
137 "is_incomplete": calculation.baseline_resolution.is_guided_default,
138 "annual_heat_basis_unit": calculation.baseline_resolution.annual_heat_basis_unit,
139 },
140 "diagnostics": {
141 "fingerprint_algorithm": FINGERPRINT_ALGORITHM,
142 "duration_ms": duration_ms,
143 },
144 }
147def handle_economics_configuration_saved(
148 study: EconomicsStudy,
149 *,
150 requires_solve: bool = False,
151 reason: str = "economics_configuration_saved",
152) -> EconomicsResultRun | int:
153 """Explicit save hook for economics editors and services.
155 Inputs that require a fresh process solve only stale current result runs.
156 Inputs that only affect presentation economics are recalculated immediately.
157 """
158 if requires_solve:
159 return mark_result_runs_stale_for_study(study=study, reason=reason, requires_solve=True)
160 return recalculate_presentation_results(study, reason=reason)
163def handle_solve_completed(scenario, *, reason: str = "solve_completed") -> list[EconomicsResultRun]:
164 """Recalculate flowsheet-level studies after a solve updates current values."""
165 recalculated_runs: list[EconomicsResultRun] = []
166 for study in EconomicsStudy.objects.filter(flowsheet=scenario.flowsheet).order_by("created_at"):
167 recalculated_runs.append(recalculate_presentation_results(study, reason=reason))
168 return recalculated_runs