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

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

46 

47 

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. 

54 

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 

95 

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 

122 

123 

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 } 

145 

146 

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. 

154 

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) 

161 

162 

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