Coverage for backend/django/Economics/results/services/lifecycle/lifecycle.py: 100%
69 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +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.inputs import calculate_study_financial_metrics
23from Economics.results.services.financial_metrics.input_snapshots import warning_payload_with_financial_input_snapshot
24from Economics.costing.capital.custom_capital_lines import sync_custom_capital_lines
25from Economics.formulas.native_properties.sync import sync_economics_native_properties_for_study
26from Economics.costing.operating.stream_properties import sync_operating_line_sources_for_study
27from Economics.results.services.lifecycle.common import duration_ms, get_assumptions, warning_payload
28from Economics.results.services.lifecycle.fingerprints import (
29 FINGERPRINT_ALGORITHM,
30 build_dependency_fingerprints,
31 _create_dependencies,
32)
33from Economics.costing.capital.generated_lines import sync_generated_capital_lines
34from Economics.results.services.lifecycle.result_lines import (
35 _create_capital_result_lines,
36 _create_cash_flow_lines,
37 _create_depreciation_result_lines,
38 _create_metric_lines,
39 _create_operating_result_lines,
40)
41from Economics.results.services.lifecycle.runs import (
42 _annotate_reused_run,
43 _mark_nonmatching_current_runs_stale,
44 _matching_result_run,
45 mark_result_runs_stale_for_study,
46)
49def recalculate_presentation_results(
50 study: EconomicsStudy,
51 *,
52 reason: str = "explicit_recalculate",
53) -> EconomicsResultRun:
54 """Reuse or synchronously calculate a current presentation result run for a study.
56 Generated capital lines are synced before fingerprinting so matching logic
57 observes the persisted rows that will later become result-line details.
58 Reused runs still have detail rows rebuilt before chart materialization so
59 older matching runs receive any newer presentation rows without changing the
60 dependency contract.
61 """
62 started_at = time.perf_counter()
63 with transaction.atomic():
64 # Generated capital lines are dependency sources, so they must be
65 # materialized before the fingerprint set is built.
66 sync_operating_line_sources_for_study(study)
67 sync_generated_capital_lines(study)
68 sync_custom_capital_lines(study)
69 fingerprints = build_dependency_fingerprints(study)
70 reusable_run = _matching_result_run(study=study, fingerprints=fingerprints)
71 if reusable_run is not None:
72 calculation = calculate_study_financial_metrics(study)
73 if reusable_run.status != ResultRunStatus.CURRENT:
74 reusable_run.status = ResultRunStatus.CURRENT
75 reusable_run.save(update_fields=["status"])
76 _mark_nonmatching_current_runs_stale(study=study, fingerprints=fingerprints, reason=reason)
77 reusable_run.warning_payload = _result_warning_payload(
78 calculation=calculation,
79 fingerprints=fingerprints,
80 reason=reason,
81 duration_ms=duration_ms(started_at),
82 )
83 reusable_run.completed_at = timezone.now()
84 reusable_run.save(update_fields=["warning_payload", "completed_at"])
85 # Matching historical runs can predate newer presentation rows, so
86 # detail rows are refreshed before charts are materialized.
87 _create_capital_result_lines(result_run=reusable_run)
88 _create_depreciation_result_lines(result_run=reusable_run)
89 _create_operating_result_lines(result_run=reusable_run)
90 _create_metric_lines(result_run=reusable_run, metrics=calculation.metrics)
91 _create_cash_flow_lines(result_run=reusable_run, rows=calculation.discounted_cash_flow)
92 materialize_chart_datasets(reusable_run)
93 sync_economics_native_properties_for_study(study)
94 _annotate_reused_run(result_run=reusable_run, reason=reason, duration_ms=duration_ms(started_at))
95 return reusable_run
97 calculation = calculate_study_financial_metrics(study)
98 _mark_nonmatching_current_runs_stale(study=study, fingerprints=fingerprints, reason=reason)
99 assumptions = get_assumptions(study)
100 run = EconomicsResultRun.objects.create(
101 flowsheet_state=study.flowsheet_state,
102 study=study,
103 status=ResultRunStatus.CURRENT,
104 result_currency=assumptions.currency if assumptions is not None else "NZD",
105 result_basis_date=assumptions.basis_date if assumptions is not None else None,
106 completed_at=timezone.now(),
107 warning_payload=_result_warning_payload(
108 calculation=calculation,
109 fingerprints=fingerprints,
110 reason=reason,
111 duration_ms=duration_ms(started_at),
112 ),
113 )
114 _create_dependencies(result_run=run, fingerprints=fingerprints)
115 _create_capital_result_lines(result_run=run)
116 _create_depreciation_result_lines(result_run=run)
117 _create_operating_result_lines(result_run=run)
118 _create_metric_lines(result_run=run, metrics=calculation.metrics)
119 _create_cash_flow_lines(result_run=run, rows=calculation.discounted_cash_flow)
120 materialize_chart_datasets(run)
121 sync_economics_native_properties_for_study(study)
122 return run
125def _result_warning_payload(
126 *,
127 calculation,
128 fingerprints,
129 reason: str,
130 duration_ms: int,
131) -> dict:
132 payload = {
133 "reason": reason,
134 "dependency_count": len(fingerprints),
135 "warnings": [warning_payload(warning) for warning in calculation.warnings],
136 "baseline": {
137 "source": calculation.baseline_resolution.source,
138 "is_incomplete": calculation.baseline_resolution.is_guided_default,
139 "annual_heat_basis_unit": calculation.baseline_resolution.annual_heat_basis_unit,
140 },
141 "diagnostics": {
142 "fingerprint_algorithm": FINGERPRINT_ALGORITHM,
143 "duration_ms": duration_ms,
144 },
145 }
146 return warning_payload_with_financial_input_snapshot(
147 warning_payload=payload,
148 snapshot_payload=calculation.input_snapshot_payload,
149 )
152def handle_economics_configuration_saved(
153 study: EconomicsStudy,
154 *,
155 requires_solve: bool = False,
156 reason: str = "economics_configuration_saved",
157) -> EconomicsResultRun | int:
158 """Explicit save hook for economics editors and services.
160 Inputs that require a fresh process solve only stale current result runs.
161 Inputs that only affect presentation economics are recalculated immediately.
162 """
163 if requires_solve:
164 return mark_result_runs_stale_for_study(study=study, reason=reason, requires_solve=True)
165 return recalculate_presentation_results(study, reason=reason)
168def handle_solve_completed(scenario, *, reason: str = "solve_completed") -> list[EconomicsResultRun]:
169 """Recalculate flowsheet-level studies after a solve updates current values."""
170 recalculated_runs: list[EconomicsResultRun] = []
171 for study in EconomicsStudy.objects.filter(flowsheet_state=scenario.flowsheet_state).order_by("created_at"):
172 recalculated_runs.append(recalculate_presentation_results(study, reason=reason))
173 return recalculated_runs