Coverage for backend/django/Economics/formulas/builders/metric_property_formulas.py: 91%

179 statements  

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

1from __future__ import annotations 

2 

3from decimal import Decimal 

4from typing import Protocol 

5 

6from Economics.studies.models import EconomicsStudy 

7from Economics.costing.capital.electrical_upgrade import derive_peak_demand_basis 

8from Economics.formulas.builders.capital import ( 

9 ELECTRICAL_UPGRADE_CAPEX, 

10 ELECTRICAL_UPGRADE_RATE, 

11 PEAK_DEMAND_BASIS_KW, 

12 build_electrical_upgrade_formula, 

13 build_target_total_capex_formula, 

14 capital_line_input_key, 

15) 

16from Economics.formulas.engine.core import FormulaError 

17from Economics.formulas.builders.metrics import BoundMetricFormula 

18from Economics.formulas.builders.native_property_formulas import ( 

19 annual_operating_total_property_expression, 

20) 

21from core.auxiliary.formula_units import formula_unit_expression 

22from Economics.settings_profiles.services.settings_profiles import get_settings_profile 

23from Economics.costing.line_properties.references import ( 

24 CAPITAL_LINE_KIND, 

25 line_property_reference, 

26 native_property_reference, 

27 native_property_value, 

28 property_mention, 

29) 

30from Economics.formulas.models import EconomicsLineFormula, EconomicsMetricFormula 

31from Economics.formulas.native_properties.specs import native_property_specs 

32 

33 

34PEAK_DEMAND = "peak_demand" 

35ELECTRICAL_UPGRADE = "electrical_upgrade" 

36ANNUAL_OPERATING_EXPENSE = "annual_operating_expense" 

37CAPEX = "capex" 

38ANNUAL_REVENUE = "annual_revenue" 

39ANNUAL_SAVINGS = "annual_savings" 

40ANNUAL_NET_BENEFIT = "annual_net_benefit" 

41INCREMENTAL_CAPEX = "incremental_capex" 

42 

43 

44class AssumptionLookup(Protocol): 

45 """Minimal assumption contract required by property-formula rendering.""" 

46 

47 def get(self, key: str, default: object = None) -> object: 

48 ... 

49 

50 

51def metric_property_formula_bindings( 

52 study: EconomicsStudy, 

53 *, 

54 metric_key: str, 

55 formula: BoundMetricFormula, 

56 assumptions: AssumptionLookup | None = None, 

57) -> dict[str, str]: 

58 """Return PropertyValue-oriented render bindings for a financial metric. 

59 

60 Metric formulas are evaluated from decimal bindings, but exposed economics 

61 properties should compose from lower-level economics properties where those 

62 properties exist. This keeps optimiser-visible formulas connected to the 

63 flowsheet instead of freezing them as scalar snapshots. 

64 """ 

65 

66 currency = _study_currency(study) 

67 annual_currency = f"{currency}/year" 

68 

69 match metric_key: 

70 case "purchase_basis_equipment" | "installed_basis_equipment" | "contingency": 

71 return _compact_bindings({ 

72 metric_key: _unit_literal(formula.bindings[metric_key], currency), 

73 }) 

74 case "peak_demand": 

75 return _compact_bindings({ 

76 "peak_demand": _unit_literal(formula.bindings["peak_demand"], "kW"), 

77 }) 

78 case "capex": 

79 target_capex = _target_capex_property_formula(study) 

80 return {"capex": target_capex} if target_capex else {} 

81 case "electrical_upgrade": 

82 return _compact_bindings({ 

83 "electrical_upgrade": _electrical_upgrade_property_formula(study), 

84 }) 

85 case "annual_opex": 

86 return {"annual_opex": _annual_operating_total_property_formula(study, include_revenue=False)} 

87 case "annual_revenue": 

88 return {"annual_revenue": _annual_operating_total_property_formula(study, include_revenue=True)} 

89 case "annual_profit": 

90 return _compact_bindings({ 

91 "target_annual_opex": _annual_opex_reference(study), 

92 "target_annual_revenue": _annual_revenue_reference(study), 

93 }) 

94 case "annual_savings": 

95 return _compact_bindings({ 

96 "baseline_annual_opex": _unit_literal( 

97 formula.bindings["baseline_annual_opex"], 

98 annual_currency, 

99 ), 

100 "target_annual_opex": _annual_opex_reference(study), 

101 }) 

102 case "annual_net_benefit": 

103 return _compact_bindings({ 

104 "annual_savings": _annual_savings_reference(study), 

105 "target_annual_revenue": _annual_revenue_reference(study), 

106 }) 

107 case "annual_depreciation": 

108 return _compact_bindings({ 

109 "annual_depreciation": _unit_literal( 

110 formula.bindings["annual_depreciation"], 

111 annual_currency, 

112 ), 

113 }) 

114 case "depreciation_tax_shield": 

115 return _compact_bindings({ 

116 "annual_depreciation": _unit_literal( 

117 formula.bindings["annual_depreciation"], 

118 annual_currency, 

119 ), 

120 "tax_rate": _decimal_literal(formula.bindings["tax_rate"]), 

121 }) 

122 case "after_tax_annual_cash_flow": 

123 return _compact_bindings({ 

124 "annual_depreciation": _unit_literal( 

125 formula.bindings["annual_depreciation"], 

126 annual_currency, 

127 ), 

128 "annual_net_benefit": _unit_literal( 

129 formula.bindings["annual_net_benefit"], 

130 annual_currency, 

131 ), 

132 "tax_rate": _decimal_literal(formula.bindings["tax_rate"]), 

133 }) 

134 case "incremental_capex": 

135 return _compact_bindings({ 

136 "baseline_capex": _unit_literal(formula.bindings["baseline_capex"], currency), 

137 "target_capex": _capex_reference(study), 

138 }) 

139 case "npv": 

140 return _compact_bindings({ 

141 "annual_cash_flow": _annual_amount( 

142 _annual_cash_flow_reference(study, formula, assumptions, annual_currency) 

143 ), 

144 "discount_rate": _decimal_literal(formula.bindings["discount_rate"]), 

145 "incremental_capex": _incremental_capex_reference(study), 

146 "residual_value": _optional_unit_literal(formula.bindings.get("residual_value"), currency), 

147 }) 

148 case "roi_percent": 148 ↛ 156line 148 didn't jump to line 156 because the pattern on line 148 always matched

149 return _compact_bindings({ 

150 "annual_cash_flow": _annual_amount( 

151 _annual_cash_flow_reference(study, formula, assumptions, annual_currency) 

152 ), 

153 "incremental_capex": _incremental_capex_reference(study), 

154 "residual_value": _optional_unit_literal(formula.bindings.get("residual_value"), currency), 

155 }) 

156 case _: 

157 return {} 

158 

159 

160def _target_capex_property_formula(study: EconomicsStudy) -> str: 

161 target_formula = build_target_total_capex_formula(study) 

162 render_bindings = {} 

163 for line in study.capital_lines.filter(included=True).order_by("pk"): 

164 line_reference = line_property_reference(study, line_kind=CAPITAL_LINE_KIND, line_id=line.pk) 

165 if line_reference: 

166 render_bindings[capital_line_input_key(line.pk)] = line_reference 

167 continue 

168 if _line_property_exists(study, line_kind=CAPITAL_LINE_KIND, line_id=line.pk): 

169 raise FormulaError( 

170 "missing_capital_line_property_reference", 

171 f"`{line.label}` is not available as a solve-visible capital cost property.", 

172 ) 

173 electrical_formula = build_electrical_upgrade_formula(study) 

174 electrical_upgrade = electrical_formula.evaluate() 

175 electrical_upgrade_reference = _electrical_upgrade_reference(study) 

176 if electrical_upgrade_reference: 

177 render_bindings[ELECTRICAL_UPGRADE_CAPEX] = electrical_upgrade_reference 

178 elif _native_property_exists(study, ELECTRICAL_UPGRADE): 

179 raise FormulaError( 

180 "missing_native_property_reference", 

181 "Electrical upgrade is not available as a solve-visible economics property.", 

182 ) 

183 elif electrical_upgrade is not None and electrical_upgrade != Decimal("0"): 

184 render_bindings[ELECTRICAL_UPGRADE_CAPEX] = electrical_formula.render_property_formula() 

185 

186 return target_formula.render_property_formula(render_bindings) 

187 

188 

189def _electrical_upgrade_property_formula(study: EconomicsStudy) -> str: 

190 electrical_formula = build_electrical_upgrade_formula(study) 

191 return electrical_formula.render_property_formula( 

192 _compact_bindings({ 

193 PEAK_DEMAND_BASIS_KW: _peak_demand_reference(study) 

194 or _unit_literal(electrical_formula.bindings[PEAK_DEMAND_BASIS_KW], "kW"), 

195 ELECTRICAL_UPGRADE_RATE: _unit_literal( 

196 electrical_formula.bindings[ELECTRICAL_UPGRADE_RATE], 

197 electrical_formula.formula.inputs[1].unit, 

198 ), 

199 }) 

200 ) 

201 

202 

203def _annual_opex_reference(study: EconomicsStudy) -> str: 

204 return _native_reference_or_fallback( 

205 study, 

206 ANNUAL_OPERATING_EXPENSE, 

207 "Annual operating expense", 

208 _annual_operating_total_property_formula(study, include_revenue=False), 

209 ) 

210 

211 

212def _annual_revenue_reference(study: EconomicsStudy) -> str: 

213 return _native_reference_or_fallback( 

214 study, 

215 ANNUAL_REVENUE, 

216 "Annual revenue", 

217 _annual_operating_total_property_formula(study, include_revenue=True), 

218 ) 

219 

220 

221def _capex_reference(study: EconomicsStudy) -> str: 

222 return _native_reference_or_fallback( 

223 study, 

224 CAPEX, 

225 "Total capital cost", 

226 _target_capex_property_formula(study), 

227 ) 

228 

229 

230def _electrical_upgrade_reference(study: EconomicsStudy) -> str: 

231 return native_property_reference(study, ELECTRICAL_UPGRADE) 

232 

233 

234def _peak_demand_reference(study: EconomicsStudy) -> str: 

235 if derive_peak_demand_basis(study).quantity_kw is None: 

236 return "" 

237 return native_property_reference(study, PEAK_DEMAND) 

238 

239 

240def _incremental_capex_reference(study: EconomicsStudy) -> str: 

241 return _native_reference_or_fallback(study, INCREMENTAL_CAPEX, "Incremental capital cost", "") 

242 

243 

244def _annual_savings_reference(study: EconomicsStudy) -> str: 

245 return _native_reference_or_fallback(study, ANNUAL_SAVINGS, "Annual savings", "") 

246 

247 

248def _annual_net_benefit_reference(study: EconomicsStudy) -> str: 

249 return _native_reference_or_fallback(study, ANNUAL_NET_BENEFIT, "Annual net benefit", "") 

250 

251 

252def _annual_cash_flow_reference( 

253 study: EconomicsStudy, 

254 formula: BoundMetricFormula, 

255 assumptions: AssumptionLookup | None, 

256 annual_currency: str, 

257) -> str: 

258 annual_cash_flow = _unit_literal(formula.bindings["annual_cash_flow"], annual_currency) 

259 annual_net_benefit = _annual_net_benefit_reference(study) 

260 if not annual_net_benefit: 

261 return annual_cash_flow 

262 tax_rate = _assumption_decimal(assumptions, "tax_rate_percent") / Decimal("100") 

263 annual_depreciation = _assumption_decimal(assumptions, "annual_depreciation") 

264 benefit_multiplier = Decimal("1") - tax_rate 

265 terms = [_multiply_dimensionless(annual_net_benefit, benefit_multiplier)] 

266 depreciation_tax_shield = annual_depreciation * tax_rate 

267 if depreciation_tax_shield: 

268 terms.append(_unit_literal(depreciation_tax_shield, annual_currency)) 

269 return terms[0] if len(terms) == 1 else f"({' + '.join(terms)})" 

270 

271 

272def _assumption_decimal(assumptions: AssumptionLookup | None, key: str) -> Decimal: 

273 if assumptions is None: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 return Decimal("0") 

275 value = assumptions.get(key) 

276 if value in (None, ""): 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 return Decimal("0") 

278 return Decimal(str(value)) 

279 

280 

281def _multiply_dimensionless(expression: str, multiplier: Decimal) -> str: 

282 if multiplier == Decimal("1"): 

283 return expression 

284 if multiplier == Decimal("-1"): 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 return f"-({expression})" 

286 if multiplier == Decimal("0"): 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 return "0" 

288 return f"({expression} * {_decimal_literal(multiplier)})" 

289 

290 

291def _native_reference_or_fallback( 

292 study: EconomicsStudy, 

293 field_key: str, 

294 label: str, 

295 fallback_formula: str, 

296) -> str: 

297 """Return a generated economics property reference when one is materialized.""" 

298 

299 value = native_property_value(study, field_key) 

300 if value is not None: 

301 return property_mention(value) 

302 if _native_property_exists(study, field_key): 

303 raise FormulaError( 

304 "missing_native_property_reference", 

305 f"{label} is not available as a solve-visible economics property.", 

306 ) 

307 return fallback_formula 

308 

309 

310def _native_property_exists(study: EconomicsStudy, field_key: str) -> bool: 

311 metric_key = _native_metric_key(study, field_key) 

312 if metric_key is None: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true

313 return False 

314 return EconomicsMetricFormula.objects.filter( 

315 flowsheet_state=study.flowsheet_state, 

316 study=study, 

317 metric_key=metric_key, 

318 property_value__isnull=False, 

319 ).exists() 

320 

321 

322def _line_property_exists(study: EconomicsStudy, *, line_kind: str, line_id: int) -> bool: 

323 return EconomicsLineFormula.objects.filter( 

324 flowsheet_state=study.flowsheet_state, 

325 study=study, 

326 line_key=f"{line_kind}_line:{line_id}", 

327 property_value__isnull=False, 

328 ).exists() 

329 

330 

331def _native_metric_key(study: EconomicsStudy, field_key: str) -> str | None: 

332 spec = next((spec for spec in native_property_specs(study) if spec.field_key == field_key), None) 

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

334 return None 

335 return spec.result_metric_key or spec.field_key 

336 

337 

338def _annual_operating_total_property_formula(study: EconomicsStudy, *, include_revenue: bool) -> str: 

339 expression = annual_operating_total_property_expression(study, include_revenue=include_revenue) 

340 if not expression.solve_visible: 

341 raise FormulaError( 

342 "missing_operating_total_property_reference", 

343 expression.blocked_reason, 

344 ) 

345 return expression.formula 

346 

347 

348def _annual_amount(expression: str) -> str: 

349 return f"({expression} * year)" if expression else "" 

350 

351 

352def _unit_literal(value: Decimal | int | str, unit: str) -> str: 

353 unit_expression = formula_unit_expression(unit) 

354 if not unit_expression: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

355 return _decimal_literal(value) 

356 decimal_value = Decimal(str(value)) 

357 if decimal_value == Decimal("0"): 

358 return "0" 

359 if decimal_value == Decimal("1"): 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 return f"({unit_expression})" 

361 if decimal_value == Decimal("-1"): 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true

362 return f"-({unit_expression})" 

363 return f"({_decimal_literal(decimal_value)} * ({unit_expression}))" 

364 

365 

366def _optional_unit_literal(value: Decimal | int | str | None, unit: str) -> str: 

367 return "" if value is None else _unit_literal(value, unit) 

368 

369 

370def _decimal_literal(value: Decimal | int | str) -> str: 

371 decimal_value = Decimal(str(value)) 

372 return format(decimal_value, "f").rstrip("0").rstrip(".") or "0" 

373 

374 

375def _study_currency(study: EconomicsStudy) -> str: 

376 assumptions = get_settings_profile(study) 

377 if assumptions is None: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 return "NZD" 

379 return assumptions.currency or "NZD" 

380 

381 

382def _compact_bindings(bindings: dict[str, str]) -> dict[str, str]: 

383 return {key: value for key, value in bindings.items() if value}