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
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1from __future__ import annotations
3from decimal import Decimal
4from typing import Protocol
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
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"
44class AssumptionLookup(Protocol):
45 """Minimal assumption contract required by property-formula rendering."""
47 def get(self, key: str, default: object = None) -> object:
48 ...
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.
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 """
66 currency = _study_currency(study)
67 annual_currency = f"{currency}/year"
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 {}
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()
186 return target_formula.render_property_formula(render_bindings)
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 )
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 )
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 )
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 )
230def _electrical_upgrade_reference(study: EconomicsStudy) -> str:
231 return native_property_reference(study, ELECTRICAL_UPGRADE)
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)
240def _incremental_capex_reference(study: EconomicsStudy) -> str:
241 return _native_reference_or_fallback(study, INCREMENTAL_CAPEX, "Incremental capital cost", "")
244def _annual_savings_reference(study: EconomicsStudy) -> str:
245 return _native_reference_or_fallback(study, ANNUAL_SAVINGS, "Annual savings", "")
248def _annual_net_benefit_reference(study: EconomicsStudy) -> str:
249 return _native_reference_or_fallback(study, ANNUAL_NET_BENEFIT, "Annual net benefit", "")
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)})"
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))
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)})"
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."""
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
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()
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()
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
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
348def _annual_amount(expression: str) -> str:
349 return f"({expression} * year)" if expression else ""
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}))"
366def _optional_unit_literal(value: Decimal | int | str | None, unit: str) -> str:
367 return "" if value is None else _unit_literal(value, unit)
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"
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"
382def _compact_bindings(bindings: dict[str, str]) -> dict[str, str]:
383 return {key: value for key, value in bindings.items() if value}