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

82 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +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 unit: str, 

51) -> BoundMetricFormula: 

52 return _bound_formula( 

53 key="annual_savings", 

54 expression=( 

55 sympy.Symbol("baseline_annual_opex") 

56 - sympy.Symbol("target_annual_opex") 

57 ), 

58 unit=unit, 

59 bindings={ 

60 "baseline_annual_opex": baseline_annual_opex, 

61 "target_annual_opex": target_annual_opex, 

62 }, 

63 ) 

64 

65 

66def annual_net_benefit_formula( 

67 *, 

68 annual_savings: Decimal, 

69 target_annual_revenue: Decimal, 

70 unit: str, 

71) -> BoundMetricFormula: 

72 return _bound_formula( 

73 key="annual_net_benefit", 

74 expression=( 

75 sympy.Symbol("annual_savings") 

76 + sympy.Symbol("target_annual_revenue") 

77 ), 

78 unit=unit, 

79 bindings={ 

80 "annual_savings": annual_savings, 

81 "target_annual_revenue": target_annual_revenue, 

82 }, 

83 ) 

84 

85 

86def depreciation_tax_shield_formula( 

87 *, 

88 annual_depreciation: Decimal, 

89 tax_rate: Decimal, 

90 unit: str, 

91) -> BoundMetricFormula: 

92 return _bound_formula( 

93 key="depreciation_tax_shield", 

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

95 unit=unit, 

96 bindings={ 

97 "annual_depreciation": annual_depreciation, 

98 "tax_rate": tax_rate, 

99 }, 

100 ) 

101 

102 

103def after_tax_annual_cash_flow_formula( 

104 *, 

105 annual_net_benefit: Decimal, 

106 annual_depreciation: Decimal, 

107 tax_rate: Decimal, 

108 unit: str, 

109) -> BoundMetricFormula: 

110 return _bound_formula( 

111 key="after_tax_annual_cash_flow", 

112 expression=( 

113 sympy.Symbol("annual_net_benefit") * (decimal_to_sympy(Decimal("1")) - sympy.Symbol("tax_rate")) 

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

115 ), 

116 unit=unit, 

117 bindings={ 

118 "annual_net_benefit": annual_net_benefit, 

119 "annual_depreciation": annual_depreciation, 

120 "tax_rate": tax_rate, 

121 }, 

122 ) 

123 

124 

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

126 return _bound_formula( 

127 key="incremental_capex", 

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

129 unit=unit, 

130 bindings={ 

131 "target_capex": target_capex, 

132 "baseline_capex": baseline_capex, 

133 }, 

134 ) 

135 

136 

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

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

139 

140 formula_input = input_key or key 

141 return _bound_formula( 

142 key=key, 

143 expression=sympy.Symbol(formula_input), 

144 unit=unit, 

145 bindings={formula_input: value}, 

146 ) 

147 

148 

149def roi_percent_formula( 

150 *, 

151 incremental_capex: Decimal, 

152 annual_cash_flow: Decimal, 

153 project_lifetime_years: int, 

154 residual_value: Decimal, 

155) -> BoundMetricFormula: 

156 incremental_capex_symbol = sympy.Symbol("incremental_capex") 

157 numerator_terms = [ 

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

159 -incremental_capex_symbol, 

160 ] 

161 bindings = { 

162 "incremental_capex": incremental_capex, 

163 "annual_cash_flow": annual_cash_flow, 

164 } 

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

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

167 bindings["residual_value"] = residual_value 

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

169 return _bound_formula( 

170 key="roi_percent", 

171 expression=expression, 

172 unit="percent", 

173 bindings=bindings, 

174 ) 

175 

176 

177def cash_flow_formula( 

178 *, 

179 year: int, 

180 project_lifetime_years: int, 

181 incremental_capex: Decimal, 

182 annual_cash_flow: Decimal, 

183 residual_value: Decimal, 

184 unit: str, 

185) -> BoundMetricFormula: 

186 return _bound_formula( 

187 key=f"cash_flow_year_{year}", 

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

189 unit=unit, 

190 bindings={ 

191 "incremental_capex": incremental_capex, 

192 "annual_cash_flow": annual_cash_flow, 

193 "residual_value": residual_value, 

194 }, 

195 ) 

196 

197 

198def discounted_cash_flow_formula( 

199 *, 

200 year: int, 

201 cash_flow: Decimal, 

202 discount_rate: Decimal, 

203 unit: str, 

204) -> BoundMetricFormula: 

205 return _bound_formula( 

206 key=f"discounted_cash_flow_year_{year}", 

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

208 unit=unit, 

209 bindings={ 

210 "cash_flow": cash_flow, 

211 "discount_rate": discount_rate, 

212 }, 

213 ) 

214 

215 

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

217 return _bound_formula( 

218 key=f"discount_factor_year_{year}", 

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

220 unit="factor", 

221 bindings={"discount_rate": discount_rate}, 

222 ) 

223 

224 

225def cumulative_cash_flow_formula( 

226 *, 

227 year: int, 

228 project_lifetime_years: int, 

229 incremental_capex: Decimal, 

230 annual_cash_flow: Decimal, 

231 residual_value: Decimal, 

232 unit: str, 

233) -> BoundMetricFormula: 

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

235 

236 return _bound_formula( 

237 key=f"cumulative_cash_flow_year_{year}", 

238 expression=sympy.Add( 

239 *( 

240 _cash_flow_expression(year=row_year, project_lifetime_years=project_lifetime_years) 

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

242 ), 

243 evaluate=False, 

244 ), 

245 unit=unit, 

246 bindings={ 

247 "incremental_capex": incremental_capex, 

248 "annual_cash_flow": annual_cash_flow, 

249 "residual_value": residual_value, 

250 }, 

251 ) 

252 

253 

254def cumulative_present_value_formula( 

255 *, 

256 key: str, 

257 year: int, 

258 project_lifetime_years: int, 

259 incremental_capex: Decimal, 

260 annual_cash_flow: Decimal, 

261 discount_rate: Decimal, 

262 residual_value: Decimal, 

263 unit: str, 

264) -> BoundMetricFormula: 

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

266 

267 return _bound_formula( 

268 key=key, 

269 expression=sympy.Add( 

270 *( 

271 _discounted_expression( 

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

273 year=row_year, 

274 ) 

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

276 ), 

277 evaluate=False, 

278 ), 

279 unit=unit, 

280 bindings={ 

281 "incremental_capex": incremental_capex, 

282 "annual_cash_flow": annual_cash_flow, 

283 "discount_rate": discount_rate, 

284 "residual_value": residual_value, 

285 }, 

286 ) 

287 

288 

289def npv_formula( 

290 *, 

291 incremental_capex: Decimal, 

292 annual_cash_flow: Decimal, 

293 project_lifetime_years: int, 

294 discount_rate: Decimal, 

295 residual_value: Decimal, 

296 unit: str, 

297) -> BoundMetricFormula: 

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

299 

300 annuity_factor = _discounted_annuity_expression( 

301 project_lifetime_years=project_lifetime_years, 

302 discount_rate=discount_rate, 

303 ) 

304 final_discount_factor = _discounted_expression( 

305 decimal_to_sympy(Decimal("1")), 

306 year=project_lifetime_years, 

307 ) 

308 terms = [ 

309 -sympy.Symbol("incremental_capex"), 

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

311 ] 

312 bindings = { 

313 "incremental_capex": incremental_capex, 

314 "annual_cash_flow": annual_cash_flow, 

315 "discount_rate": discount_rate, 

316 } 

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

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

319 bindings["residual_value"] = residual_value 

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

321 return _bound_formula( 

322 key="npv", 

323 expression=expression, 

324 unit=unit, 

325 bindings=bindings, 

326 ) 

327 

328 

329def _bound_formula( 

330 *, 

331 key: str, 

332 expression: sympy.Expr, 

333 unit: str, 

334 bindings: dict[str, Decimal], 

335) -> BoundMetricFormula: 

336 return BoundMetricFormula( 

337 formula=EconomicsFormula( 

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

339 expression=expression, 

340 unit=unit, 

341 inputs=tuple( 

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

343 for input_key in bindings 

344 ), 

345 ), 

346 bindings=bindings, 

347 ) 

348 

349 

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

351 decimal_value = Decimal(str(value)) 

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

353 

354 

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

356 if year == 0: 

357 return -sympy.Symbol("incremental_capex") 

358 expression = sympy.Symbol("annual_cash_flow") 

359 if year == project_lifetime_years: 

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

361 return expression 

362 

363 

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

365 if year == 0: 

366 return expression 

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

368 return expression / discount_denominator 

369 

370 

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

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

373 return decimal_to_sympy(project_lifetime_years) 

374 discount_rate_symbol = sympy.Symbol("discount_rate") 

375 return ( 

376 decimal_to_sympy(Decimal("1")) 

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

378 ) / discount_rate_symbol