Coverage for backend/django/Economics/results/services/financial_metrics/input_snapshots.py: 92%

133 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1"""Financial calculation input snapshots for persisted and comparison flows.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Mapping 

6from dataclasses import dataclass 

7from decimal import Decimal, InvalidOperation, ROUND_HALF_UP 

8from typing import Any 

9 

10from pydantic import ValidationError 

11 

12from Economics.costing.capital.custom_capital_lines import base_capex_for_custom_percentage_lines 

13from Economics.costing.capital.electrical_upgrade import PeakDemandBasis, derive_peak_demand_basis 

14from Economics.costing.operating.schedule_calculation import calculate_operating_line 

15from Economics.formulas.builders.capital import build_electrical_upgrade_formula, build_target_total_capex_formula 

16from Economics.formulas.builders.operating import operating_line_is_revenue 

17from Economics.results.services.financial_metrics.baselines import ( 

18 resolve_baseline_for_study, 

19 target_assumptions_for_study, 

20) 

21from Economics.results.services.financial_metrics.contracts import ( 

22 ZERO, 

23 BaselineResolution, 

24 FinancialCalculationInputs, 

25 FinancialMetricsError, 

26 FinancialWarning, 

27 TargetAssumptions, 

28) 

29from Economics.shared.payloads import json_ready 

30from Economics.settings_profiles.services.depreciation import DepreciationSchedule, build_straight_line_depreciation_schedule 

31from Economics.studies.models import EconomicsStudy 

32 

33 

34FINANCIAL_INPUT_SNAPSHOT_PAYLOAD_KEY = "financial_input_snapshot" 

35 

36 

37@dataclass(frozen=True) 

38class FinancialInputSnapshot: 

39 """Target financial inputs before baseline-dependent assumptions are applied.""" 

40 

41 study: EconomicsStudy 

42 target_capex: Decimal 

43 target_annual_opex: Decimal 

44 target_annual_revenue: Decimal 

45 target_annual_depreciation: Decimal 

46 target_purchase_basis_capex: Decimal 

47 target_installed_basis_capex: Decimal 

48 target_contingency_capex: Decimal 

49 target_electrical_upgrade_capex: Decimal 

50 target_peak_demand_kw: Decimal | None 

51 target_assumptions: TargetAssumptions 

52 

53 def to_payload(self) -> dict[str, Any]: 

54 """Serialize the snapshot for result-run JSON metadata.""" 

55 

56 return json_ready( 

57 { 

58 "version": 1, 

59 "target_capex": self.target_capex, 

60 "target_annual_opex": self.target_annual_opex, 

61 "target_annual_revenue": self.target_annual_revenue, 

62 "target_annual_depreciation": self.target_annual_depreciation, 

63 "target_purchase_basis_capex": self.target_purchase_basis_capex, 

64 "target_installed_basis_capex": self.target_installed_basis_capex, 

65 "target_contingency_capex": self.target_contingency_capex, 

66 "target_electrical_upgrade_capex": self.target_electrical_upgrade_capex, 

67 "target_peak_demand_kw": self.target_peak_demand_kw, 

68 "target_assumptions": self.target_assumptions.model_dump(mode="json"), 

69 } 

70 ) 

71 

72 def calculation_inputs( 

73 self, 

74 *, 

75 baseline_resolution: BaselineResolution, 

76 warnings: tuple[FinancialWarning, ...] | list[FinancialWarning] = (), 

77 ) -> FinancialCalculationInputs: 

78 """Return pure calculation inputs using a resolved baseline contract.""" 

79 

80 return FinancialCalculationInputs( 

81 target_capex=self.target_capex, 

82 target_annual_opex=self.target_annual_opex, 

83 target_annual_revenue=self.target_annual_revenue, 

84 target_annual_depreciation=self.target_annual_depreciation, 

85 target_purchase_basis_capex=self.target_purchase_basis_capex, 

86 target_installed_basis_capex=self.target_installed_basis_capex, 

87 target_contingency_capex=self.target_contingency_capex, 

88 target_electrical_upgrade_capex=self.target_electrical_upgrade_capex, 

89 target_peak_demand_kw=self.target_peak_demand_kw, 

90 baseline_capex=baseline_resolution.capex, 

91 baseline_annual_opex=baseline_resolution.annual_opex, 

92 project_lifetime_years=baseline_resolution.project_lifetime_years, 

93 discount_rate_percent=baseline_resolution.discount_rate_percent, 

94 tax_rate_percent=self.target_assumptions.tax_rate_percent, 

95 residual_value=baseline_resolution.residual_value, 

96 baseline_fully_calculated=( 

97 not baseline_resolution.is_guided_default 

98 and baseline_resolution.capex is not None 

99 and baseline_resolution.annual_opex is not None 

100 ), 

101 assumptions=self.target_assumptions.as_assumption_set().merge_set(baseline_resolution.assumptions), 

102 warnings=tuple(warnings), 

103 ) 

104 

105 

106def build_financial_input_snapshot( 

107 *, 

108 study: EconomicsStudy, 

109 target_capex: Decimal, 

110 capital_breakdown: dict[str, Decimal], 

111 depreciation_schedule: DepreciationSchedule, 

112 target_annual_opex: Decimal, 

113 target_annual_revenue: Decimal, 

114 peak_demand_basis: PeakDemandBasis, 

115 target_assumptions: TargetAssumptions, 

116) -> FinancialInputSnapshot: 

117 """Create a snapshot from lifecycle-owned target calculations.""" 

118 

119 return FinancialInputSnapshot( 

120 study=study, 

121 target_capex=target_capex, 

122 target_annual_opex=target_annual_opex, 

123 target_annual_revenue=target_annual_revenue, 

124 target_annual_depreciation=depreciation_schedule.annual_depreciation, 

125 target_purchase_basis_capex=capital_breakdown["purchase_basis"], 

126 target_installed_basis_capex=capital_breakdown["installed_basis"], 

127 target_contingency_capex=capital_breakdown["contingency"], 

128 target_electrical_upgrade_capex=capital_breakdown["electrical_upgrade"], 

129 target_peak_demand_kw=peak_demand_basis.quantity_kw, 

130 target_assumptions=target_assumptions, 

131 ) 

132 

133 

134def snapshot_from_study_target_state(study: EconomicsStudy) -> FinancialInputSnapshot: 

135 """Build a financial input snapshot from the study's current persisted target state.""" 

136 

137 target_capex = target_total_capex(study) 

138 capital_breakdown = target_capital_breakdown(study) 

139 depreciation_schedule = build_straight_line_depreciation_schedule(study) 

140 target_annual_opex, target_annual_revenue = target_operating_totals(study) 

141 peak_demand_basis = derive_peak_demand_basis(study) 

142 target_assumptions = target_assumptions_for_study(study) 

143 return build_financial_input_snapshot( 

144 study=study, 

145 target_capex=target_capex, 

146 capital_breakdown=capital_breakdown, 

147 depreciation_schedule=depreciation_schedule, 

148 target_annual_opex=target_annual_opex, 

149 target_annual_revenue=target_annual_revenue, 

150 peak_demand_basis=peak_demand_basis, 

151 target_assumptions=target_assumptions, 

152 ) 

153 

154 

155def snapshot_from_result_run(result_run) -> FinancialInputSnapshot: 

156 """Load the structured financial input snapshot persisted with a result run.""" 

157 

158 snapshot_payload = financial_input_snapshot_payload_for_result_run(result_run) 

159 if not isinstance(snapshot_payload, Mapping): 

160 raise FinancialMetricsError( 

161 "financial_input_snapshot_missing", 

162 "The saved result run does not include structured financial calculation inputs.", 

163 context={"result_run_id": result_run.pk, "target_study_id": result_run.study_id}, 

164 ) 

165 return financial_input_snapshot_from_payload(study=result_run.study, payload=snapshot_payload) 

166 

167 

168def financial_input_snapshot_from_payload( 

169 *, 

170 study: EconomicsStudy, 

171 payload: Mapping[str, Any], 

172) -> FinancialInputSnapshot: 

173 """Deserialize a result-run financial input snapshot payload.""" 

174 

175 try: 

176 if payload.get("version") != 1: 

177 raise ValueError("version") 

178 target_assumptions = TargetAssumptions.model_validate(payload["target_assumptions"]) 

179 if target_assumptions.target_study_id != study.pk: 

180 raise ValueError("target_assumptions.target_study_id") 

181 return FinancialInputSnapshot( 

182 study=study, 

183 target_capex=_required_decimal(payload, "target_capex"), 

184 target_annual_opex=_required_decimal(payload, "target_annual_opex"), 

185 target_annual_revenue=_required_decimal(payload, "target_annual_revenue"), 

186 target_annual_depreciation=_required_decimal(payload, "target_annual_depreciation"), 

187 target_purchase_basis_capex=_required_decimal(payload, "target_purchase_basis_capex"), 

188 target_installed_basis_capex=_required_decimal(payload, "target_installed_basis_capex"), 

189 target_contingency_capex=_required_decimal(payload, "target_contingency_capex"), 

190 target_electrical_upgrade_capex=_required_decimal(payload, "target_electrical_upgrade_capex"), 

191 target_peak_demand_kw=_optional_decimal(payload.get("target_peak_demand_kw")), 

192 target_assumptions=target_assumptions, 

193 ) 

194 except (KeyError, TypeError, ValueError, ValidationError) as exc: 

195 raise FinancialMetricsError( 

196 "financial_input_snapshot_invalid", 

197 "The saved result run has invalid structured financial calculation inputs.", 

198 context={"target_study_id": study.pk, "field": str(exc)}, 

199 ) from exc 

200 

201 

202def financial_input_snapshot_payload_for_result_run(result_run) -> object: 

203 """Return the raw snapshot payload from a persisted result run. 

204 

205 Result runs currently store calculation metadata in their JSON warning 

206 payload. Keeping that access behind this helper prevents comparison and 

207 lifecycle code from treating the diagnostics field as their own contract. 

208 """ 

209 

210 payload = result_run.warning_payload if isinstance(result_run.warning_payload, Mapping) else {} 

211 return payload.get(FINANCIAL_INPUT_SNAPSHOT_PAYLOAD_KEY) 

212 

213 

214def warning_payload_with_financial_input_snapshot( 

215 *, 

216 warning_payload: Mapping[str, Any], 

217 snapshot_payload: Mapping[str, Any] | None, 

218) -> dict[str, Any]: 

219 """Attach the structured financial input snapshot to a result-run payload.""" 

220 

221 payload = dict(warning_payload) 

222 if snapshot_payload is not None: 

223 payload[FINANCIAL_INPUT_SNAPSHOT_PAYLOAD_KEY] = dict(snapshot_payload) 

224 return payload 

225 

226 

227def calculation_inputs_for_study( 

228 *, 

229 study: EconomicsStudy, 

230 snapshot: FinancialInputSnapshot, 

231) -> tuple[FinancialCalculationInputs, BaselineResolution, list[FinancialWarning]]: 

232 """Resolve the study's selected baseline and return calculation inputs.""" 

233 

234 baseline_resolution, warnings = resolve_baseline_for_study( 

235 study=study, 

236 target_capex=snapshot.target_capex, 

237 target_annual_opex=snapshot.target_annual_opex, 

238 target_assumptions=snapshot.target_assumptions, 

239 ) 

240 return ( 

241 snapshot.calculation_inputs( 

242 baseline_resolution=baseline_resolution, 

243 warnings=tuple(warnings), 

244 ), 

245 baseline_resolution, 

246 warnings, 

247 ) 

248 

249 

250def target_base_capital_cost(study: EconomicsStudy) -> Decimal: 

251 """Return the generated unit-operation CAPEX subtotal for native properties.""" 

252 

253 return base_capex_for_custom_percentage_lines(study) 

254 

255 

256def target_annual_operating_expense(study: EconomicsStudy) -> Decimal: 

257 """Return annual operating expenses before output revenue offsets.""" 

258 

259 expense_total, _revenue_total = target_operating_totals(study) 

260 return expense_total 

261 

262 

263def target_total_capex(study: EconomicsStudy) -> Decimal: 

264 """Return the target study's total capital cost.""" 

265 

266 total_formula = build_target_total_capex_formula(study) 

267 total = total_formula.evaluate() 

268 if total is None: 

269 raise FinancialMetricsError( 

270 "target_capex_formula_blocked", 

271 total_formula.formula.blocked_reason, 

272 context={"blocked_children": total_formula.formula.blocked_children}, 

273 ) 

274 return total.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) 

275 

276 

277def target_capital_breakdown(study: EconomicsStudy) -> dict[str, Decimal]: 

278 """Return target capital subtotals used by financial metrics.""" 

279 

280 totals = { 

281 "purchase_basis": ZERO, 

282 "installed_basis": ZERO, 

283 "contingency": ZERO, 

284 "electrical_upgrade": ZERO, 

285 } 

286 for payload in study.capital_lines.filter(included=True).values_list("warning_payload", flat=True): 

287 if not isinstance(payload, Mapping): 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true

288 continue 

289 totals["purchase_basis"] += _decimal_from_payload(payload.get("purchase_basis_amount")) 

290 totals["installed_basis"] += _decimal_from_payload(payload.get("installed_basis_amount")) 

291 totals["contingency"] += _decimal_from_payload(payload.get("contingency_amount")) 

292 totals["electrical_upgrade"] = _electrical_upgrade_capex(study) 

293 return totals 

294 

295 

296def target_operating_totals(study: EconomicsStudy) -> tuple[Decimal, Decimal]: 

297 """Return target annual expense and revenue totals.""" 

298 

299 expense_total = ZERO 

300 revenue_total = ZERO 

301 blocked_children = [] 

302 lines = study.operating_lines.filter(included=True).select_related( 

303 "source_default_rate", 

304 "source_property_info", 

305 ) 

306 for line in lines.order_by("pk"): 

307 calculation = calculate_operating_line(line, study=study) 

308 if calculation.annual_amount is None: 

309 blocked_children.append( 

310 { 

311 "key": f"operating_line:{line.pk}", 

312 "reason": calculation.blocked_reason or "Operating line cannot be calculated.", 

313 } 

314 ) 

315 continue 

316 if operating_line_is_revenue(line): 

317 revenue_total += calculation.annual_amount 

318 else: 

319 expense_total += calculation.annual_amount 

320 if blocked_children: 

321 raise FinancialMetricsError( 

322 "target_annual_opex_formula_blocked", 

323 "Annual operating costs cannot be calculated until all included operating lines are complete.", 

324 context={"blocked_children": blocked_children}, 

325 ) 

326 return expense_total, revenue_total 

327 

328 

329def _electrical_upgrade_capex(study: EconomicsStudy) -> Decimal: 

330 """Calculate project-level electrical-upgrade capex without creating a capital line.""" 

331 

332 formula = build_electrical_upgrade_formula(study) 

333 amount = formula.evaluate() 

334 if amount is None: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 raise FinancialMetricsError( 

336 "electrical_upgrade_formula_blocked", 

337 formula.formula.blocked_reason, 

338 context={"blocked_children": formula.formula.blocked_children}, 

339 ) 

340 return amount 

341 

342 

343def _decimal_from_payload(value: object) -> Decimal: 

344 if value in (None, ""): 

345 return ZERO 

346 try: 

347 return Decimal(str(value)) 

348 except (InvalidOperation, ValueError): 

349 return ZERO 

350 

351 

352def _required_decimal(payload: Mapping[str, Any], key: str) -> Decimal: 

353 value = payload[key] 

354 try: 

355 return Decimal(str(value)) 

356 except (InvalidOperation, ValueError, TypeError) as exc: 

357 raise ValueError(key) from exc 

358 

359 

360def _optional_decimal(value: object) -> Decimal | None: 

361 if value in (None, ""): 

362 return None 

363 try: 

364 return Decimal(str(value)) 

365 except (InvalidOperation, ValueError, TypeError) as exc: 

366 raise ValueError("target_peak_demand_kw") from exc