Coverage for backend/django/Economics/formulas/builders/capital.py: 79%
256 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 dataclasses import dataclass
4from decimal import Decimal, ROUND_HALF_UP
5from typing import Mapping
7import sympy
9from Economics.shared.choices import CapitalLineBasis, CostBasis, CostCurveEvaluationKind
11from Economics.reference_data.models import CostIndexValue
13from Economics.settings_profiles.models import EconomicsSettingsProfile
14from Economics.costing.capital.capital_line_sources import GENERATED_CAPITAL_LINE_SOURCE
15from Economics.costing.capital.electrical_upgrade import derive_peak_demand_basis
16from Economics.costing.capital.lang_factors import resolve_lang_factor
17from Economics.settings_profiles.services.settings_profiles import get_settings_profile
19from Economics.formulas.engine.core import EconomicsFormula, FormulaError, FormulaInput, FormulaStep, decimal_to_sympy
20from Economics.formulas.engine.parsing import parse_cost_expression
23@dataclass(frozen=True)
24class CapitalIndexAdjustment:
25 factor: Decimal
26 detail: str
29@dataclass(frozen=True)
30class GeneratedCapitalLineFormula:
31 formula: EconomicsFormula
32 index_adjustment: CapitalIndexAdjustment
33 lang_factor: Decimal | None
34 lang_factor_source: str
35 applies_lang_factor: bool
36 contingency_percent: Decimal
37 contingency_factor: Decimal
40@dataclass(frozen=True)
41class BoundCapitalFormula:
42 formula: EconomicsFormula
43 bindings: Mapping[str, Decimal]
45 def evaluate(self) -> Decimal | None:
46 return self.formula.evaluate(self.bindings)
48 def render_property_formula(self, render_bindings: Mapping[str, str] | None = None) -> str:
49 rendered_bindings = {key: _decimal_literal(value) for key, value in self.bindings.items()}
50 rendered_bindings.update(render_bindings or {})
51 return self.formula.render_property_formula(rendered_bindings)
54CUSTOM_CAPEX_PERCENTAGE_BASIS = "custom_capex_percentage_basis"
55GENERATED_UNIT_CAPEX_SUBTOTAL = "generated_unit_capex_subtotal"
56CUSTOM_CAPITAL_TOTAL = "custom_capital_total"
57ELECTRICAL_UPGRADE_CAPEX = "electrical_upgrade_capex"
58PEAK_DEMAND_BASIS_KW = "peak_demand_kw"
59ELECTRICAL_UPGRADE_RATE = "electrical_upgrade_rate"
60CAPITAL_RESULT_QUANTUM = Decimal("0.0001")
63def build_cost_curve_formula(curve) -> EconomicsFormula:
64 """Build the canonical formula for an expression cost curve."""
65 if curve.evaluation_kind != CostCurveEvaluationKind.EXPRESSION: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 raise FormulaError(
67 "invalid_cost_curve_evaluation_kind",
68 "Only expression curves have a top-level cost formula.",
69 context={"curve_key": curve.curve_key, "evaluation_kind": curve.evaluation_kind},
70 )
71 expression_text = (curve.expression_text or "").strip()
72 if not expression_text: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 raise FormulaError(
74 "missing_expression_text",
75 "Expression cost curves require expression_text.",
76 context={"curve_key": curve.curve_key},
77 )
78 formula_specs = _formula_input_specs(curve)
79 expression = parse_cost_expression(
80 expression_text,
81 variable_symbols=[spec.variable_symbol for spec in formula_specs],
82 )
83 return EconomicsFormula(
84 key=f"cost_curve:{curve.pk or curve.curve_key}",
85 expression=expression,
86 unit=curve.output_unit,
87 inputs=tuple(
88 FormulaInput(
89 key=spec.variable_symbol,
90 label=spec.label,
91 unit=spec.unit,
92 )
93 for spec in formula_specs
94 ),
95 )
98def render_cost_curve_formula(curve, *, formula_variable: str) -> str:
99 formula = build_cost_curve_formula(curve)
100 primary_input = next((formula_input for formula_input in formula.inputs), None)
101 if primary_input is None:
102 raise FormulaError(
103 "missing_formula_variables",
104 "Cost curve formula has no declared formula variables.",
105 context={"curve_key": curve.curve_key},
106 )
107 return formula.render_property_formula({primary_input.key: formula_variable})
110def build_cost_curve_variant_formula(curve, variant, *, formula_specs) -> EconomicsFormula:
111 """Build one formula candidate from a discrete-family variant expression."""
112 expression = parse_cost_expression(
113 variant.expression_text,
114 variable_symbols=[spec.variable_symbol for spec in formula_specs],
115 )
116 return EconomicsFormula(
117 key=f"cost_curve_variant:{curve.pk or curve.curve_key}:{variant.key}",
118 expression=expression,
119 unit=curve.output_unit,
120 inputs=tuple(
121 FormulaInput(
122 key=spec.variable_symbol,
123 label=spec.label,
124 unit=spec.unit,
125 )
126 for spec in formula_specs
127 ),
128 )
131def build_generated_capital_line_formula(
132 mapping,
133 driver=None,
134 existing_line=None,
135 *,
136 calculation_cache: dict | None = None,
137) -> GeneratedCapitalLineFormula:
138 """Compose curve, index, Lang-factor, and contingency arithmetic once."""
139 del driver, existing_line
140 curve = mapping.cost_curve
141 if curve is None: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 raise FormulaError(
143 "missing_cost_curve",
144 "Generated capital line formulas require a selected cost curve.",
145 context={"costable_item_id": mapping.costable_item_id},
146 )
147 curve_formula = (
148 build_cost_curve_formula(curve)
149 if curve.evaluation_kind == CostCurveEvaluationKind.EXPRESSION
150 else _placeholder_discrete_curve_formula(curve)
151 )
152 index_key = ("capital_index_adjustment", mapping.costable_item.study_id, curve.pk)
153 if calculation_cache is not None and index_key in calculation_cache:
154 index_adjustment = calculation_cache[index_key]
155 else:
156 index_adjustment = capital_index_adjustment(mapping.costable_item.study, curve)
157 if calculation_cache is not None:
158 calculation_cache[index_key] = index_adjustment
159 lang_factor_resolution = resolve_lang_factor(mapping)
160 applies_lang_factor = (
161 curve.cost_basis == CostBasis.PURCHASE
162 and lang_factor_resolution.effective_value is not None
163 )
164 contingency_key = ("contingency_percent", mapping.costable_item.study_id)
165 if calculation_cache is not None and contingency_key in calculation_cache:
166 contingency_percent = calculation_cache[contingency_key]
167 else:
168 contingency_percent = contingency_percent_for_study(mapping.costable_item.study) or Decimal("0")
169 if calculation_cache is not None:
170 calculation_cache[contingency_key] = contingency_percent
171 contingency_factor = Decimal("1") + (contingency_percent / Decimal("100"))
173 expression = curve_formula.expression
174 steps = [
175 FormulaStep(
176 kind="base_curve_cost",
177 label="Curve base cost",
178 expression=curve_formula.audit_payload()["formula"],
179 unit=curve.output_unit,
180 )
181 ]
182 expression = _multiply_factor(
183 expression,
184 index_adjustment.factor,
185 steps=steps,
186 kind="index_adjustment",
187 label="CPI/index adjustment",
188 detail=index_adjustment.detail,
189 )
190 if applies_lang_factor: 190 ↛ 199line 190 didn't jump to line 199 because the condition on line 190 was always true
191 expression = _multiply_factor(
192 expression,
193 lang_factor_resolution.effective_value,
194 steps=steps,
195 kind="lang_factor",
196 label="Lang factor",
197 detail=lang_factor_resolution.source,
198 )
199 expression = _multiply_factor(
200 expression,
201 contingency_factor,
202 steps=steps,
203 kind="contingency",
204 label="Contingency",
205 percent=contingency_percent,
206 )
207 return GeneratedCapitalLineFormula(
208 formula=EconomicsFormula(
209 key=f"generated_capital_line:{mapping.pk}",
210 expression=expression,
211 unit=curve.output_unit,
212 inputs=curve_formula.inputs,
213 steps=tuple(steps),
214 ),
215 index_adjustment=index_adjustment,
216 lang_factor=lang_factor_resolution.effective_value,
217 lang_factor_source=lang_factor_resolution.source,
218 applies_lang_factor=applies_lang_factor,
219 contingency_percent=contingency_percent,
220 contingency_factor=contingency_factor,
221 )
224def _formula_input_specs(curve):
225 from Economics.costing.cost_curves.driver_specs import parse_required_driver_specs
227 try:
228 specs = parse_required_driver_specs(curve.required_driver_specs)
229 except ValueError as exc:
230 raise FormulaError(
231 "invalid_cost_curve_driver_specs",
232 str(exc),
233 context={"curve_key": curve.curve_key},
234 ) from exc
235 return tuple(spec for spec in specs if spec.role == "formula_input")
238def _placeholder_discrete_curve_formula(curve) -> EconomicsFormula:
239 """Provide factor steps for discrete curves before runtime variant selection.
241 Generated capital recalculation evaluates discrete candidates in
242 ``evaluate_cost_curve``. This placeholder keeps index/Lang/contingency
243 composition centralized without pretending there is a top-level expression.
244 """
245 return EconomicsFormula(
246 key=f"cost_curve:{curve.pk or curve.curve_key}",
247 expression=decimal_to_sympy(Decimal("1")),
248 unit=curve.output_unit,
249 inputs=(),
250 solve_visible=False,
251 )
254def build_generated_unit_capex_subtotal_formula(study) -> BoundCapitalFormula:
255 """Build the generated unit-operation CAPEX subtotal formula."""
256 terms: list[sympy.Expr] = []
257 blocked_children: list[dict[str, str]] = []
258 for line in (
259 study.capital_lines.filter(
260 included=True,
261 source=GENERATED_CAPITAL_LINE_SOURCE,
262 )
263 .exclude(costable_item__simulation_object__is_deleted=True)
264 .order_by("pk")
265 ):
266 if line.amount is None and line.cost_curve_id is not None:
267 blocked_children.append(
268 {
269 "key": f"capital_line:{line.pk}",
270 "reason": "included generated capital line has no amount",
271 }
272 )
273 continue
274 if line.amount is None:
275 continue
276 terms.append(decimal_to_sympy(line.amount))
277 return BoundCapitalFormula(
278 formula=EconomicsFormula(
279 key="generated_unit_capex_subtotal",
280 expression=_sum_expressions(terms),
281 unit=_study_currency(study),
282 inputs=(),
283 steps=(
284 FormulaStep(
285 kind="generated_capital_line_sum",
286 label="Generated unit CAPEX subtotal",
287 expression="sum(included generated capital lines)",
288 amount=None,
289 unit=_study_currency(study),
290 ),
291 ),
292 missing_child_policy="strict_included_children",
293 blocked_children=tuple(blocked_children),
294 blocked_reason="Included generated capital lines are missing amounts."
295 if blocked_children
296 else "",
297 ),
298 bindings={},
299 )
302def build_custom_capital_line_formula(line, *, base_capex: Decimal) -> BoundCapitalFormula:
303 """Build a formula for a fixed or percentage custom capital line."""
304 currency = line.currency or _study_currency(line.study)
305 if line.calculation_basis != CapitalLineBasis.BASE_CAPEX_PERCENT:
306 if line.amount is None: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true
307 raise FormulaError(
308 "missing_custom_capital_amount",
309 "Fixed custom capital lines require an amount.",
310 context={"capital_line_id": line.pk},
311 )
312 return BoundCapitalFormula(
313 formula=EconomicsFormula(
314 key=f"custom_capital_line:{line.pk or 'unsaved'}",
315 expression=decimal_to_sympy(line.amount),
316 unit=currency,
317 inputs=(),
318 steps=(
319 FormulaStep(
320 kind="fixed_capital_amount",
321 label="Fixed capital amount",
322 expression=str(line.amount),
323 amount=line.amount,
324 unit=currency,
325 ),
326 ),
327 ),
328 bindings={},
329 )
330 if line.basis_percent is None: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 raise FormulaError(
332 "missing_custom_capital_percent",
333 "Percentage custom capital lines require a basis percent.",
334 context={"capital_line_id": line.pk},
335 )
336 basis_symbol = sympy.Symbol(CUSTOM_CAPEX_PERCENTAGE_BASIS)
337 percent_factor = line.basis_percent / Decimal("100")
338 return BoundCapitalFormula(
339 formula=EconomicsFormula(
340 key=f"custom_capital_line:{line.pk or 'unsaved'}",
341 expression=sympy.Mul(basis_symbol, decimal_to_sympy(percent_factor), evaluate=False),
342 unit=currency,
343 inputs=(
344 FormulaInput(
345 key=CUSTOM_CAPEX_PERCENTAGE_BASIS,
346 label="Custom CAPEX percentage basis",
347 unit=currency,
348 ),
349 ),
350 steps=(
351 FormulaStep(
352 kind="base_capex_percent",
353 label="Base CAPEX percentage",
354 expression=str(percent_factor),
355 amount=percent_factor,
356 unit="factor",
357 ),
358 ),
359 ),
360 bindings={CUSTOM_CAPEX_PERCENTAGE_BASIS: base_capex},
361 )
364def build_custom_capital_total_formula(study, *, base_capex: Decimal | None = None) -> BoundCapitalFormula:
365 """Build the included custom capital total formula."""
366 generated_subtotal = base_capex
367 if generated_subtotal is None:
368 generated_subtotal_formula = build_generated_unit_capex_subtotal_formula(study)
369 generated_subtotal = generated_subtotal_formula.evaluate()
370 if generated_subtotal is None:
371 return BoundCapitalFormula(
372 formula=EconomicsFormula(
373 key="custom_capital_total",
374 expression=decimal_to_sympy(Decimal("0")),
375 unit=_study_currency(study),
376 inputs=(),
377 missing_child_policy="strict_included_children",
378 blocked_children=(
379 {
380 "key": generated_subtotal_formula.formula.key,
381 "reason": generated_subtotal_formula.formula.blocked_reason,
382 },
383 ),
384 blocked_reason="Custom capital total requires generated unit CAPEX subtotal.",
385 ),
386 bindings={},
387 )
388 terms: list[sympy.Expr] = []
389 bindings: dict[str, Decimal] = {}
390 inputs: list[FormulaInput] = []
391 blocked_children: list[dict[str, str]] = []
392 for line in study.capital_lines.filter(included=True).exclude(source=GENERATED_CAPITAL_LINE_SOURCE).order_by("pk"):
393 try:
394 line_formula = build_custom_capital_line_formula(line, base_capex=generated_subtotal)
395 amount = line_formula.evaluate()
396 except FormulaError as exc:
397 amount = None
398 blocked_children.append({"key": f"capital_line:{line.pk}", "reason": exc.message})
399 if amount is None:
400 continue
401 if line.calculation_basis == CapitalLineBasis.BASE_CAPEX_PERCENT:
402 bindings[CUSTOM_CAPEX_PERCENTAGE_BASIS] = generated_subtotal
403 if not any(formula_input.key == CUSTOM_CAPEX_PERCENTAGE_BASIS for formula_input in inputs):
404 inputs.extend(line_formula.formula.inputs)
405 terms.append(line_formula.formula.expression)
406 return BoundCapitalFormula(
407 formula=EconomicsFormula(
408 key="custom_capital_total",
409 expression=_sum_expressions(terms),
410 unit=_study_currency(study),
411 inputs=tuple(inputs),
412 steps=(
413 FormulaStep(
414 kind="custom_capital_total",
415 label="Custom capital total",
416 expression="sum(included custom capital line formulas)",
417 unit=_study_currency(study),
418 ),
419 ),
420 missing_child_policy="strict_included_children",
421 blocked_children=tuple(blocked_children),
422 blocked_reason="Included custom capital lines are blocked." if blocked_children else "",
423 ),
424 bindings=bindings,
425 )
428def build_peak_demand_formula(study) -> BoundCapitalFormula:
429 """Build the peak-demand capacity formula used for electrical upgrade CAPEX."""
430 peak_demand_basis = derive_peak_demand_basis(study)
431 quantity_kw = peak_demand_basis.quantity_kw or Decimal("0")
432 blocked_children = (
433 (
434 {
435 "key": "scheduled_peak_demand",
436 "reason": peak_demand_basis.blocked_reason,
437 },
438 )
439 if peak_demand_basis.blocked_reason
440 else ()
441 )
442 return BoundCapitalFormula(
443 formula=EconomicsFormula(
444 key="peak_demand_capacity",
445 expression=decimal_to_sympy(quantity_kw),
446 unit=peak_demand_basis.unit,
447 inputs=(),
448 steps=(
449 FormulaStep(
450 kind="peak_demand_sum",
451 label="Peak demand capacity",
452 expression="sum(included capital line peak demand)",
453 amount=quantity_kw,
454 unit=peak_demand_basis.unit,
455 ),
456 ),
457 missing_child_policy="omitted_before_formula_when_not_applicable",
458 blocked_children=blocked_children,
459 blocked_reason=peak_demand_basis.blocked_reason,
460 ),
461 bindings={},
462 )
465def build_electrical_upgrade_formula(study) -> BoundCapitalFormula:
466 """Build the electrical-upgrade CAPEX formula from power capacity and rate."""
467 assumptions = get_settings_profile(study)
468 if assumptions is None:
469 rate_amount = Decimal("0")
470 currency = "NZD"
471 else:
472 rate_amount = assumptions.electrical_upgrade_rate_amount or Decimal("0")
473 currency = assumptions.currency or "NZD"
474 peak_formula = build_peak_demand_formula(study)
475 peak_demand = peak_formula.evaluate()
476 blocked_children = ()
477 blocked_reason = ""
478 if peak_demand is None and peak_formula.formula.blocked_reason:
479 blocked_children = (
480 {
481 "key": peak_formula.formula.key,
482 "reason": peak_formula.formula.blocked_reason,
483 },
484 )
485 blocked_reason = "Electrical upgrade requires complete scheduled peak-demand values."
486 peak_symbol = sympy.Symbol(PEAK_DEMAND_BASIS_KW)
487 rate_symbol = sympy.Symbol(ELECTRICAL_UPGRADE_RATE)
488 return BoundCapitalFormula(
489 formula=EconomicsFormula(
490 key="electrical_upgrade_capex",
491 expression=sympy.Mul(peak_symbol, rate_symbol, evaluate=False),
492 unit=currency,
493 inputs=(
494 FormulaInput(
495 key=PEAK_DEMAND_BASIS_KW,
496 label="Peak demand capacity",
497 unit="kW",
498 ),
499 FormulaInput(
500 key=ELECTRICAL_UPGRADE_RATE,
501 label="Electrical upgrade rate",
502 unit=f"{currency}/kW",
503 ),
504 ),
505 steps=(
506 FormulaStep(
507 kind="electrical_upgrade_rate",
508 label="Electrical upgrade rate",
509 expression=str(rate_amount),
510 amount=rate_amount,
511 unit=f"{currency}/kW",
512 ),
513 ),
514 blocked_children=blocked_children,
515 blocked_reason=blocked_reason,
516 ),
517 bindings={
518 PEAK_DEMAND_BASIS_KW: peak_demand or Decimal("0"),
519 ELECTRICAL_UPGRADE_RATE: rate_amount,
520 },
521 )
524def build_target_total_capex_formula(study) -> BoundCapitalFormula:
525 """Build the target total CAPEX formula from included capital-line formulas."""
526 blocked_children: list[dict[str, str]] = []
527 terms: list[sympy.Expr] = []
528 inputs: list[FormulaInput] = []
529 bindings: dict[str, Decimal] = {}
530 generated_subtotal = build_generated_unit_capex_subtotal_formula(study).evaluate() or Decimal("0")
531 for line in study.capital_lines.filter(included=True).order_by("pk"):
532 amount = _capital_line_amount_for_total(line, generated_subtotal=generated_subtotal)
533 if amount is None:
534 if line.cost_curve_id is not None:
535 blocked_children.append(
536 {
537 "key": f"capital_line:{line.pk}",
538 "reason": "included capital line has no amount",
539 }
540 )
541 continue
542 symbol_key = capital_line_input_key(line.pk)
543 terms.append(sympy.Symbol(symbol_key))
544 inputs.append(
545 FormulaInput(
546 key=symbol_key,
547 label=line.label,
548 unit=_study_currency(study),
549 )
550 )
551 bindings[symbol_key] = amount
552 electrical_formula = build_electrical_upgrade_formula(study)
553 electrical_upgrade = electrical_formula.evaluate()
554 if electrical_upgrade is None:
555 blocked_children.append({"key": electrical_formula.formula.key, "reason": electrical_formula.formula.blocked_reason})
556 electrical_upgrade = Decimal("0")
557 terms.append(sympy.Symbol(ELECTRICAL_UPGRADE_CAPEX))
558 inputs.append(
559 FormulaInput(
560 key=ELECTRICAL_UPGRADE_CAPEX,
561 label="Electrical upgrade CAPEX",
562 unit=_study_currency(study),
563 )
564 )
565 bindings[ELECTRICAL_UPGRADE_CAPEX] = electrical_upgrade
566 return BoundCapitalFormula(
567 formula=EconomicsFormula(
568 key="target_total_capex",
569 expression=_sum_expressions(terms),
570 unit=_study_currency(study),
571 inputs=tuple(inputs),
572 steps=(
573 FormulaStep(
574 kind="target_total_capex",
575 label="Target total CAPEX",
576 expression="generated subtotal + custom capital + electrical upgrade",
577 unit=_study_currency(study),
578 ),
579 ),
580 missing_child_policy="strict_included_children",
581 blocked_children=tuple(blocked_children),
582 blocked_reason="Target total CAPEX has blocked children." if blocked_children else "",
583 ),
584 bindings=bindings,
585 )
588def capital_line_input_key(line_id: int) -> str:
589 return f"capital_line_{line_id}"
592def _capital_line_amount_for_total(line, *, generated_subtotal: Decimal) -> Decimal | None:
593 if line.source == GENERATED_CAPITAL_LINE_SOURCE:
594 return line.amount
595 try:
596 return build_custom_capital_line_formula(line, base_capex=generated_subtotal).evaluate()
597 except FormulaError:
598 return None
601def capital_index_adjustment(study, curve) -> CapitalIndexAdjustment:
602 """Resolve the capital index factor applied to a curve-backed cost."""
603 assumptions = get_settings_profile(study)
604 if assumptions is None or assumptions.capital_index_series_id is None:
605 return CapitalIndexAdjustment(
606 factor=Decimal("1"),
607 detail="No capital index series selected",
608 )
609 target_value = _capital_index_target_value(assumptions)
610 if target_value is None:
611 raise FormulaError(
612 "missing_capital_index_target_value",
613 "Selected capital index series has no target value for the study basis date.",
614 context={
615 "study_id": study.pk,
616 "series_id": assumptions.capital_index_series_id,
617 "basis_date": None if assumptions.basis_date is None else assumptions.basis_date.isoformat(),
618 },
619 )
620 basis_value, basis_label = _capital_index_basis_value(assumptions, curve)
621 if basis_value in (None, Decimal("0")): 621 ↛ 622line 621 didn't jump to line 622 because the condition on line 621 was never true
622 raise FormulaError(
623 "missing_capital_index_basis_value",
624 "Selected capital index series has no curve basis value.",
625 context={
626 "curve_key": curve.curve_key,
627 "series_id": assumptions.capital_index_series_id,
628 "basis": basis_label,
629 },
630 )
631 factor = target_value.value / basis_value
632 return CapitalIndexAdjustment(
633 factor=factor,
634 detail=(
635 f"{target_value.series.name}: {target_value.period} "
636 f"{target_value.value} / {basis_label} {basis_value}"
637 ),
638 )
641def contingency_percent_for_study(study) -> Decimal | None:
642 """Return the study contingency percentage applied to generated capital costs."""
643 assumptions = get_settings_profile(study)
644 if assumptions is None or assumptions.contingency_percent is None: 644 ↛ 645line 644 didn't jump to line 645 because the condition on line 644 was never true
645 return None
646 return assumptions.contingency_percent
649def _multiply_factor(
650 expression: sympy.Expr,
651 factor: Decimal,
652 *,
653 steps: list[FormulaStep],
654 kind: str,
655 label: str,
656 detail: str = "",
657 percent: Decimal | None = None,
658) -> sympy.Expr:
659 if factor == Decimal("1"):
660 return expression
661 steps.append(
662 FormulaStep(
663 kind=kind,
664 label=label,
665 expression=str(factor),
666 amount=factor,
667 unit="factor",
668 )
669 )
670 return sympy.Mul(expression, decimal_to_sympy(factor), evaluate=False)
673def _sum_expressions(terms: list[sympy.Expr]) -> sympy.Expr:
674 if not terms:
675 return decimal_to_sympy(Decimal("0"))
676 if len(terms) == 1:
677 return terms[0]
678 return sympy.Add(*terms, evaluate=False)
681def _study_currency(study) -> str:
682 assumptions = get_settings_profile(study)
683 if assumptions is None:
684 return "NZD"
685 return assumptions.currency or "NZD"
688def _capital_result_amount(value: Decimal) -> Decimal:
689 return value.quantize(CAPITAL_RESULT_QUANTUM, rounding=ROUND_HALF_UP)
692def _decimal_literal(value: Decimal | int | str) -> str:
693 decimal_value = Decimal(str(value))
694 return format(decimal_value, "f").rstrip("0").rstrip(".") or "0"
697def _capital_index_basis_value(assumptions: EconomicsSettingsProfile, curve) -> tuple[Decimal | None, str]:
698 """Resolve the curve-side index value used as the escalation denominator."""
699 if curve.basis_index_value is not None: 699 ↛ 700line 699 didn't jump to line 700 because the condition on line 699 was never true
700 basis_label = curve.basis_date.isoformat() if curve.basis_date else "curve basis"
701 return curve.basis_index_value, basis_label
702 if curve.basis_date is None: 702 ↛ 703line 702 didn't jump to line 703 because the condition on line 702 was never true
703 return None, "curve basis"
704 basis_value = (
705 assumptions.capital_index_series.values.filter(period_date__lte=curve.basis_date)
706 .order_by("-period_date", "-pk")
707 .first()
708 )
709 if basis_value is None: 709 ↛ 710line 709 didn't jump to line 710 because the condition on line 709 was never true
710 return None, curve.basis_date.isoformat()
711 return basis_value.value, basis_value.period
714def _capital_index_target_value(assumptions: EconomicsSettingsProfile) -> CostIndexValue | None:
715 """Resolve the latest selected index value at or before the study basis date."""
716 values = assumptions.capital_index_series.values.order_by("-period_date", "-pk")
717 if assumptions.basis_date is not None: 717 ↛ 719line 717 didn't jump to line 719 because the condition on line 717 was always true
718 values = values.filter(period_date__lte=assumptions.basis_date)
719 return values.first()