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
« prev ^ index » next coverage.py v7.10.7, created at 2026-06-23 21:51 +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.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
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"
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 "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 {}
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()
198 return target_formula.render_property_formula(render_bindings)
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 )
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 )
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 )
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 )
242def _electrical_upgrade_reference(study: EconomicsStudy) -> str:
243 return native_property_reference(study, ELECTRICAL_UPGRADE)
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)
252def _incremental_capex_reference(study: EconomicsStudy) -> str:
253 return _native_reference_or_fallback(study, INCREMENTAL_CAPEX, "Incremental capital cost", "")
256def _annual_savings_reference(study: EconomicsStudy) -> str:
257 return _native_reference_or_fallback(study, ANNUAL_SAVINGS, "Annual savings", "")
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)})"
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))
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)})"
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."""
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
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()
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()
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
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
356def _annual_amount(expression: str) -> str:
357 return f"({expression} * year)" if expression else ""
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}))"
374def _optional_unit_literal(value: Decimal | int | str | None, unit: str) -> str:
375 return "" if value is None else _unit_literal(value, unit)
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"
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"
390def _compact_bindings(bindings: dict[str, str]) -> dict[str, str]:
391 return {key: value for key, value in bindings.items() if value}