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
« prev ^ index » next coverage.py v7.10.7, created at 2026-06-23 21:51 +0000
1from __future__ import annotations
3from dataclasses import dataclass
4from decimal import Decimal
5from typing import Mapping
7import sympy
9from Economics.formulas.engine.core import EconomicsFormula, FormulaInput, decimal_to_sympy
12@dataclass(frozen=True)
13class BoundMetricFormula:
14 formula: EconomicsFormula
15 bindings: dict[str, Decimal]
17 def evaluate(self) -> Decimal | None:
18 return self.formula.evaluate(self.bindings)
20 def render_property_formula(self, render_bindings: Mapping[str, str] | None = None) -> str:
21 """Render the formula for PropertyValue storage.
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 """
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)
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 )
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 )
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 )
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 )
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 )
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."""
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 )
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 )
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 )
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 )
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 )
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 )
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``."""
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 )
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``."""
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 )
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."""
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 )
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 )
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"
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
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
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