Coverage for backend/django/Economics/formulas/builders/metrics.py: 100%

90 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-06-23 21:51 +0000

1from __future__ import annotations 

2 

3from dataclasses import dataclass 

4from decimal import Decimal 

5from typing import Mapping 

6 

7import sympy 

8 

9from Economics.formulas.engine.core import EconomicsFormula, FormulaInput, decimal_to_sympy 

10 

11 

12@dataclass(frozen=True) 

13class BoundMetricFormula: 

14 formula: EconomicsFormula 

15 bindings: dict[str, Decimal] 

16 

17 def evaluate(self) -> Decimal | None: 

18 return self.formula.evaluate(self.bindings) 

19 

20 def render_property_formula(self, render_bindings: Mapping[str, str] | None = None) -> str: 

21 """Render the formula for PropertyValue storage. 

22 

23 Numeric bindings remain the source of truth for evaluation, but callers 

24 can override selected symbols with references to lower-level 

25 PropertyValues so rendered economics properties compose like ordinary 

26 formulas instead of collapsing everything to constants. 

27 """ 

28 

29 rendered_bindings = {key: _decimal_literal(value) for key, value in self.bindings.items()} 

30 rendered_bindings.update(render_bindings or {}) 

31 return self.formula.render_property_formula(rendered_bindings) 

32 

33 

34def annual_profit_formula(*, target_annual_revenue: Decimal, target_annual_opex: Decimal, unit: str) -> BoundMetricFormula: 

35 return _bound_formula( 

36 key="annual_profit", 

37 expression=sympy.Symbol("target_annual_revenue") - sympy.Symbol("target_annual_opex"), 

38 unit=unit, 

39 bindings={ 

40 "target_annual_revenue": target_annual_revenue, 

41 "target_annual_opex": target_annual_opex, 

42 }, 

43 ) 

44 

45 

46def annual_savings_formula( 

47 *, 

48 baseline_annual_opex: Decimal, 

49 target_annual_opex: Decimal, 

50 target_annual_revenue: Decimal, 

51 unit: str, 

52) -> BoundMetricFormula: 

53 return _bound_formula( 

54 key="annual_savings", 

55 expression=( 

56 sympy.Symbol("baseline_annual_opex") 

57 - sympy.Symbol("target_annual_opex") 

58 + sympy.Symbol("target_annual_revenue") 

59 ), 

60 unit=unit, 

61 bindings={ 

62 "baseline_annual_opex": baseline_annual_opex, 

63 "target_annual_opex": target_annual_opex, 

64 "target_annual_revenue": target_annual_revenue, 

65 }, 

66 ) 

67 

68 

69def depreciation_tax_shield_formula( 

70 *, 

71 annual_depreciation: Decimal, 

72 tax_rate: Decimal, 

73 unit: str, 

74) -> BoundMetricFormula: 

75 return _bound_formula( 

76 key="depreciation_tax_shield", 

77 expression=sympy.Symbol("annual_depreciation") * sympy.Symbol("tax_rate"), 

78 unit=unit, 

79 bindings={ 

80 "annual_depreciation": annual_depreciation, 

81 "tax_rate": tax_rate, 

82 }, 

83 ) 

84 

85 

86def after_tax_annual_cash_flow_formula( 

87 *, 

88 annual_savings: Decimal, 

89 annual_depreciation: Decimal, 

90 tax_rate: Decimal, 

91 unit: str, 

92) -> BoundMetricFormula: 

93 return _bound_formula( 

94 key="after_tax_annual_cash_flow", 

95 expression=( 

96 sympy.Symbol("annual_savings") * (decimal_to_sympy(Decimal("1")) - sympy.Symbol("tax_rate")) 

97 + sympy.Symbol("annual_depreciation") * sympy.Symbol("tax_rate") 

98 ), 

99 unit=unit, 

100 bindings={ 

101 "annual_savings": annual_savings, 

102 "annual_depreciation": annual_depreciation, 

103 "tax_rate": tax_rate, 

104 }, 

105 ) 

106 

107 

108def incremental_capex_formula(*, target_capex: Decimal, baseline_capex: Decimal, unit: str) -> BoundMetricFormula: 

109 return _bound_formula( 

110 key="incremental_capex", 

111 expression=sympy.Symbol("target_capex") - sympy.Symbol("baseline_capex"), 

112 unit=unit, 

113 bindings={ 

114 "target_capex": target_capex, 

115 "baseline_capex": baseline_capex, 

116 }, 

117 ) 

118 

119 

120def metric_value_formula(*, key: str, value: Decimal, unit: str, input_key: str | None = None) -> BoundMetricFormula: 

121 """Wrap an already-resolved scalar so metric rows still use formula evaluation.""" 

122 

123 formula_input = input_key or key 

124 return _bound_formula( 

125 key=key, 

126 expression=sympy.Symbol(formula_input), 

127 unit=unit, 

128 bindings={formula_input: value}, 

129 ) 

130 

131 

132def roi_percent_formula( 

133 *, 

134 incremental_capex: Decimal, 

135 annual_cash_flow: Decimal, 

136 project_lifetime_years: int, 

137 residual_value: Decimal, 

138) -> BoundMetricFormula: 

139 incremental_capex_symbol = sympy.Symbol("incremental_capex") 

140 numerator_terms = [ 

141 sympy.Symbol("annual_cash_flow") * decimal_to_sympy(project_lifetime_years), 

142 -incremental_capex_symbol, 

143 ] 

144 bindings = { 

145 "incremental_capex": incremental_capex, 

146 "annual_cash_flow": annual_cash_flow, 

147 } 

148 if residual_value != Decimal("0"): 

149 numerator_terms.append(sympy.Symbol("residual_value")) 

150 bindings["residual_value"] = residual_value 

151 expression = sympy.Add(*numerator_terms, evaluate=False) / incremental_capex_symbol * decimal_to_sympy(Decimal("100")) 

152 return _bound_formula( 

153 key="roi_percent", 

154 expression=expression, 

155 unit="percent", 

156 bindings=bindings, 

157 ) 

158 

159 

160def lcoh_formula( 

161 *, 

162 target_capex: Decimal, 

163 target_annual_opex: Decimal, 

164 target_annual_process_energy_basis: Decimal, 

165 project_lifetime_years: int, 

166 discount_rate: Decimal, 

167 residual_value: Decimal, 

168 unit: str, 

169) -> BoundMetricFormula: 

170 annuity_factor = _discounted_annuity_expression( 

171 project_lifetime_years=project_lifetime_years, 

172 discount_rate=discount_rate, 

173 ) 

174 final_discount_factor = _discounted_expression( 

175 decimal_to_sympy(Decimal("1")), 

176 year=project_lifetime_years, 

177 ) 

178 numerator_terms = [ 

179 sympy.Symbol("target_capex"), 

180 sympy.Symbol("target_annual_opex") * annuity_factor, 

181 ] 

182 bindings = { 

183 "target_capex": target_capex, 

184 "target_annual_opex": target_annual_opex, 

185 "target_annual_process_energy_basis": target_annual_process_energy_basis, 

186 "discount_rate": discount_rate, 

187 } 

188 if residual_value != Decimal("0"): 

189 numerator_terms.append(-sympy.Symbol("residual_value") * final_discount_factor) 

190 bindings["residual_value"] = residual_value 

191 expression = sympy.Add(*numerator_terms, evaluate=False) / ( 

192 sympy.Symbol("target_annual_process_energy_basis") * annuity_factor 

193 ) 

194 return _bound_formula( 

195 key="lcoh", 

196 expression=expression, 

197 unit=unit, 

198 bindings=bindings, 

199 ) 

200 

201 

202def cash_flow_formula( 

203 *, 

204 year: int, 

205 project_lifetime_years: int, 

206 incremental_capex: Decimal, 

207 annual_cash_flow: Decimal, 

208 residual_value: Decimal, 

209 unit: str, 

210) -> BoundMetricFormula: 

211 return _bound_formula( 

212 key=f"cash_flow_year_{year}", 

213 expression=_cash_flow_expression(year=year, project_lifetime_years=project_lifetime_years), 

214 unit=unit, 

215 bindings={ 

216 "incremental_capex": incremental_capex, 

217 "annual_cash_flow": annual_cash_flow, 

218 "residual_value": residual_value, 

219 }, 

220 ) 

221 

222 

223def discounted_cash_flow_formula( 

224 *, 

225 year: int, 

226 cash_flow: Decimal, 

227 discount_rate: Decimal, 

228 unit: str, 

229) -> BoundMetricFormula: 

230 return _bound_formula( 

231 key=f"discounted_cash_flow_year_{year}", 

232 expression=_discounted_expression(sympy.Symbol("cash_flow"), year=year), 

233 unit=unit, 

234 bindings={ 

235 "cash_flow": cash_flow, 

236 "discount_rate": discount_rate, 

237 }, 

238 ) 

239 

240 

241def discount_factor_formula(*, year: int, discount_rate: Decimal) -> BoundMetricFormula: 

242 return _bound_formula( 

243 key=f"discount_factor_year_{year}", 

244 expression=_discounted_expression(decimal_to_sympy(Decimal("1")), year=year), 

245 unit="factor", 

246 bindings={"discount_rate": discount_rate}, 

247 ) 

248 

249 

250def cumulative_cash_flow_formula( 

251 *, 

252 year: int, 

253 project_lifetime_years: int, 

254 incremental_capex: Decimal, 

255 annual_cash_flow: Decimal, 

256 residual_value: Decimal, 

257 unit: str, 

258) -> BoundMetricFormula: 

259 """Build the cumulative undiscounted cash-flow formula through ``year``.""" 

260 

261 return _bound_formula( 

262 key=f"cumulative_cash_flow_year_{year}", 

263 expression=sympy.Add( 

264 *( 

265 _cash_flow_expression(year=row_year, project_lifetime_years=project_lifetime_years) 

266 for row_year in range(0, year + 1) 

267 ), 

268 evaluate=False, 

269 ), 

270 unit=unit, 

271 bindings={ 

272 "incremental_capex": incremental_capex, 

273 "annual_cash_flow": annual_cash_flow, 

274 "residual_value": residual_value, 

275 }, 

276 ) 

277 

278 

279def cumulative_present_value_formula( 

280 *, 

281 key: str, 

282 year: int, 

283 project_lifetime_years: int, 

284 incremental_capex: Decimal, 

285 annual_cash_flow: Decimal, 

286 discount_rate: Decimal, 

287 residual_value: Decimal, 

288 unit: str, 

289) -> BoundMetricFormula: 

290 """Build the cumulative discounted cash-flow formula through ``year``.""" 

291 

292 return _bound_formula( 

293 key=key, 

294 expression=sympy.Add( 

295 *( 

296 _discounted_expression( 

297 _cash_flow_expression(year=row_year, project_lifetime_years=project_lifetime_years), 

298 year=row_year, 

299 ) 

300 for row_year in range(0, year + 1) 

301 ), 

302 evaluate=False, 

303 ), 

304 unit=unit, 

305 bindings={ 

306 "incremental_capex": incremental_capex, 

307 "annual_cash_flow": annual_cash_flow, 

308 "discount_rate": discount_rate, 

309 "residual_value": residual_value, 

310 }, 

311 ) 

312 

313 

314def npv_formula( 

315 *, 

316 incremental_capex: Decimal, 

317 annual_cash_flow: Decimal, 

318 project_lifetime_years: int, 

319 discount_rate: Decimal, 

320 residual_value: Decimal, 

321 unit: str, 

322) -> BoundMetricFormula: 

323 """Build NPV as the final cumulative discounted cash-flow formula.""" 

324 

325 annuity_factor = _discounted_annuity_expression( 

326 project_lifetime_years=project_lifetime_years, 

327 discount_rate=discount_rate, 

328 ) 

329 final_discount_factor = _discounted_expression( 

330 decimal_to_sympy(Decimal("1")), 

331 year=project_lifetime_years, 

332 ) 

333 terms = [ 

334 -sympy.Symbol("incremental_capex"), 

335 sympy.Symbol("annual_cash_flow") * annuity_factor, 

336 ] 

337 bindings = { 

338 "incremental_capex": incremental_capex, 

339 "annual_cash_flow": annual_cash_flow, 

340 "discount_rate": discount_rate, 

341 } 

342 if residual_value != Decimal("0"): 

343 terms.append(sympy.Symbol("residual_value") * final_discount_factor) 

344 bindings["residual_value"] = residual_value 

345 expression = sympy.Add(*terms, evaluate=False) 

346 return _bound_formula( 

347 key="npv", 

348 expression=expression, 

349 unit=unit, 

350 bindings=bindings, 

351 ) 

352 

353 

354def _bound_formula( 

355 *, 

356 key: str, 

357 expression: sympy.Expr, 

358 unit: str, 

359 bindings: dict[str, Decimal], 

360) -> BoundMetricFormula: 

361 return BoundMetricFormula( 

362 formula=EconomicsFormula( 

363 key=f"metric:{key}", 

364 expression=expression, 

365 unit=unit, 

366 inputs=tuple( 

367 FormulaInput(key=input_key, label=input_key.replace("_", " "), unit="") 

368 for input_key in bindings 

369 ), 

370 ), 

371 bindings=bindings, 

372 ) 

373 

374 

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

376 decimal_value = Decimal(str(value)) 

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

378 

379 

380def _cash_flow_expression(*, year: int, project_lifetime_years: int) -> sympy.Expr: 

381 if year == 0: 

382 return -sympy.Symbol("incremental_capex") 

383 expression = sympy.Symbol("annual_cash_flow") 

384 if year == project_lifetime_years: 

385 expression += sympy.Symbol("residual_value") 

386 return expression 

387 

388 

389def _discounted_expression(expression: sympy.Expr, *, year: int) -> sympy.Expr: 

390 if year == 0: 

391 return expression 

392 discount_denominator = (decimal_to_sympy(Decimal("1")) + sympy.Symbol("discount_rate")) ** year 

393 return expression / discount_denominator 

394 

395 

396def _discounted_annuity_expression(*, project_lifetime_years: int, discount_rate: Decimal) -> sympy.Expr: 

397 if discount_rate == Decimal("0"): 

398 return decimal_to_sympy(project_lifetime_years) 

399 discount_rate_symbol = sympy.Symbol("discount_rate") 

400 return ( 

401 decimal_to_sympy(Decimal("1")) 

402 - (decimal_to_sympy(Decimal("1")) + discount_rate_symbol) ** -project_lifetime_years 

403 ) / discount_rate_symbol