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

181 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-06-23 21:51 +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.operating.resource_basis import derive_target_process_energy_basis 

24from Economics.costing.line_properties.references import ( 

25 CAPITAL_LINE_KIND, 

26 line_property_reference, 

27 native_property_reference, 

28 native_property_value, 

29 property_mention, 

30) 

31from Economics.formulas.models import EconomicsLineFormula, EconomicsMetricFormula 

32from Economics.formulas.native_properties.specs import native_property_specs 

33 

34 

35PEAK_DEMAND = "peak_demand" 

36ELECTRICAL_UPGRADE = "electrical_upgrade" 

37ANNUAL_OPERATING_EXPENSE = "annual_operating_expense" 

38CAPEX = "capex" 

39ANNUAL_REVENUE = "annual_revenue" 

40ANNUAL_SAVINGS = "annual_savings" 

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 "target_annual_revenue": _annual_revenue_reference(study), 

102 }) 

103 case "annual_depreciation": 

104 return _compact_bindings({ 

105 "annual_depreciation": _unit_literal( 

106 formula.bindings["annual_depreciation"], 

107 annual_currency, 

108 ), 

109 }) 

110 case "depreciation_tax_shield": 

111 return _compact_bindings({ 

112 "annual_depreciation": _unit_literal( 

113 formula.bindings["annual_depreciation"], 

114 annual_currency, 

115 ), 

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

117 }) 

118 case "after_tax_annual_cash_flow": 

119 return _compact_bindings({ 

120 "annual_depreciation": _unit_literal( 

121 formula.bindings["annual_depreciation"], 

122 annual_currency, 

123 ), 

124 "annual_savings": _unit_literal( 

125 formula.bindings["annual_savings"], 

126 annual_currency, 

127 ), 

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

129 }) 

130 case "incremental_capex": 

131 return _compact_bindings({ 

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

133 "target_capex": _capex_reference(study), 

134 }) 

135 case "npv": 

136 return _compact_bindings({ 

137 "annual_cash_flow": _annual_amount( 

138 _annual_cash_flow_reference(study, formula, assumptions, annual_currency) 

139 ), 

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

141 "incremental_capex": _incremental_capex_reference(study), 

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

143 }) 

144 case "roi_percent": 

145 return _compact_bindings({ 

146 "annual_cash_flow": _annual_amount( 

147 _annual_cash_flow_reference(study, formula, assumptions, annual_currency) 

148 ), 

149 "incremental_capex": _incremental_capex_reference(study), 

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

151 }) 

152 case "lcoh": 152 ↛ 168line 152 didn't jump to line 168 because the pattern on line 152 always matched

153 process_energy_basis = derive_target_process_energy_basis(study) 

154 target_energy_binding = _decimal_literal(formula.bindings["target_annual_process_energy_basis"]) 

155 if process_energy_basis.unit: 155 ↛ 159line 155 didn't jump to line 159 because the condition on line 155 was always true

156 target_energy_binding = _annual_amount( 

157 _unit_literal(formula.bindings["target_annual_process_energy_basis"], process_energy_basis.unit) 

158 ) 

159 return _compact_bindings({ 

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

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

162 "target_annual_opex": _annual_amount( 

163 _annual_opex_reference(study) 

164 ), 

165 "target_annual_process_energy_basis": target_energy_binding, 

166 "target_capex": _capex_reference(study), 

167 }) 

168 case _: 

169 return {} 

170 

171 

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

173 target_formula = build_target_total_capex_formula(study) 

174 render_bindings = {} 

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

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

177 if line_reference: 

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

179 continue 

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

181 raise FormulaError( 

182 "missing_capital_line_property_reference", 

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

184 ) 

185 electrical_formula = build_electrical_upgrade_formula(study) 

186 electrical_upgrade = electrical_formula.evaluate() 

187 electrical_upgrade_reference = _electrical_upgrade_reference(study) 

188 if electrical_upgrade_reference: 

189 render_bindings[ELECTRICAL_UPGRADE_CAPEX] = electrical_upgrade_reference 

190 elif _native_property_exists(study, ELECTRICAL_UPGRADE): 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 raise FormulaError( 

192 "missing_native_property_reference", 

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

194 ) 

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

196 render_bindings[ELECTRICAL_UPGRADE_CAPEX] = electrical_formula.render_property_formula() 

197 

198 return target_formula.render_property_formula(render_bindings) 

199 

200 

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

202 electrical_formula = build_electrical_upgrade_formula(study) 

203 return electrical_formula.render_property_formula( 

204 _compact_bindings({ 

205 PEAK_DEMAND_BASIS_KW: _peak_demand_reference(study) 

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

207 ELECTRICAL_UPGRADE_RATE: _unit_literal( 

208 electrical_formula.bindings[ELECTRICAL_UPGRADE_RATE], 

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

210 ), 

211 }) 

212 ) 

213 

214 

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

216 return _native_reference_or_fallback( 

217 study, 

218 ANNUAL_OPERATING_EXPENSE, 

219 "Annual operating expense", 

220 _annual_operating_total_property_formula(study, include_revenue=False), 

221 ) 

222 

223 

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

225 return _native_reference_or_fallback( 

226 study, 

227 ANNUAL_REVENUE, 

228 "Annual revenue", 

229 _annual_operating_total_property_formula(study, include_revenue=True), 

230 ) 

231 

232 

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

234 return _native_reference_or_fallback( 

235 study, 

236 CAPEX, 

237 "Total capital cost", 

238 _target_capex_property_formula(study), 

239 ) 

240 

241 

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

243 return native_property_reference(study, ELECTRICAL_UPGRADE) 

244 

245 

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

247 if derive_peak_demand_basis(study).quantity_kw is None: 

248 return "" 

249 return native_property_reference(study, PEAK_DEMAND) 

250 

251 

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

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

254 

255 

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

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

258 

259 

260def _annual_cash_flow_reference( 

261 study: EconomicsStudy, 

262 formula: BoundMetricFormula, 

263 assumptions: AssumptionLookup | None, 

264 annual_currency: str, 

265) -> str: 

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

267 annual_savings = _annual_savings_reference(study) 

268 if not annual_savings: 

269 return annual_cash_flow 

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

271 annual_depreciation = _assumption_decimal(assumptions, "annual_depreciation") 

272 savings_multiplier = Decimal("1") - tax_rate 

273 terms = [_multiply_dimensionless(annual_savings, savings_multiplier)] 

274 depreciation_tax_shield = annual_depreciation * tax_rate 

275 if depreciation_tax_shield: 

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

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

278 

279 

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

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

282 return Decimal("0") 

283 value = assumptions.get(key) 

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

285 return Decimal("0") 

286 return Decimal(str(value)) 

287 

288 

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

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

291 return expression 

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

293 return f"-({expression})" 

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

295 return "0" 

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

297 

298 

299def _native_reference_or_fallback( 

300 study: EconomicsStudy, 

301 field_key: str, 

302 label: str, 

303 fallback_formula: str, 

304) -> str: 

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

306 

307 value = native_property_value(study, field_key) 

308 if value is not None: 

309 return property_mention(value) 

310 if _native_property_exists(study, field_key): 

311 raise FormulaError( 

312 "missing_native_property_reference", 

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

314 ) 

315 return fallback_formula 

316 

317 

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

319 metric_key = _native_metric_key(study, field_key) 

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

321 return False 

322 return EconomicsMetricFormula.objects.filter( 

323 flowsheet=study.flowsheet, 

324 study=study, 

325 metric_key=metric_key, 

326 property_value__isnull=False, 

327 ).exists() 

328 

329 

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

331 return EconomicsLineFormula.objects.filter( 

332 flowsheet=study.flowsheet, 

333 study=study, 

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

335 property_value__isnull=False, 

336 ).exists() 

337 

338 

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

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

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

342 return None 

343 return spec.result_metric_key or spec.field_key 

344 

345 

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

347 expression = annual_operating_total_property_expression(study, include_revenue=include_revenue) 

348 if not expression.solve_visible: 

349 raise FormulaError( 

350 "missing_operating_total_property_reference", 

351 expression.blocked_reason, 

352 ) 

353 return expression.formula 

354 

355 

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

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

358 

359 

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

361 unit_expression = formula_unit_expression(unit) 

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

363 return _decimal_literal(value) 

364 decimal_value = Decimal(str(value)) 

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

366 return "0" 

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

368 return f"({unit_expression})" 

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

370 return f"-({unit_expression})" 

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

372 

373 

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

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

376 

377 

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

379 decimal_value = Decimal(str(value)) 

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

381 

382 

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

384 assumptions = get_settings_profile(study) 

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

386 return "NZD" 

387 return assumptions.currency or "NZD" 

388 

389 

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

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