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

1"""Public orchestration facade for Economics presentation result lifecycle. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11import time 

12 

13from django.db import transaction 

14from django.utils import timezone 

15 

16from Economics.results.models import EconomicsResultRun 

17 

18from Economics.studies.models import EconomicsStudy 

19 

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) 

47 

48 

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. 

55 

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 

96 

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 

123 

124 

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 ) 

150 

151 

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. 

159 

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) 

166 

167 

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