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

1from __future__ import annotations 

2 

3from dataclasses import dataclass 

4from decimal import Decimal, ROUND_HALF_UP 

5from typing import Mapping 

6 

7import sympy 

8 

9from Economics.shared.choices import CapitalLineBasis, CostBasis, CostCurveEvaluationKind 

10 

11from Economics.reference_data.models import CostIndexValue 

12 

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 

18 

19from Economics.formulas.engine.core import EconomicsFormula, FormulaError, FormulaInput, FormulaStep, decimal_to_sympy 

20from Economics.formulas.engine.parsing import parse_cost_expression 

21 

22 

23@dataclass(frozen=True) 

24class CapitalIndexAdjustment: 

25 factor: Decimal 

26 detail: str 

27 

28 

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 

38 

39 

40@dataclass(frozen=True) 

41class BoundCapitalFormula: 

42 formula: EconomicsFormula 

43 bindings: Mapping[str, Decimal] 

44 

45 def evaluate(self) -> Decimal | None: 

46 return self.formula.evaluate(self.bindings) 

47 

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) 

52 

53 

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") 

61 

62 

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 ) 

96 

97 

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}) 

108 

109 

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 ) 

129 

130 

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")) 

172 

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 ) 

222 

223 

224def _formula_input_specs(curve): 

225 from Economics.costing.cost_curves.driver_specs import parse_required_driver_specs 

226 

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") 

236 

237 

238def _placeholder_discrete_curve_formula(curve) -> EconomicsFormula: 

239 """Provide factor steps for discrete curves before runtime variant selection. 

240 

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 ) 

252 

253 

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 ) 

300 

301 

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 ) 

362 

363 

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 ) 

426 

427 

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 ) 

463 

464 

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 ) 

522 

523 

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 ) 

586 

587 

588def capital_line_input_key(line_id: int) -> str: 

589 return f"capital_line_{line_id}" 

590 

591 

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 

599 

600 

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 ) 

639 

640 

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 

647 

648 

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) 

671 

672 

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) 

679 

680 

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" 

686 

687 

688def _capital_result_amount(value: Decimal) -> Decimal: 

689 return value.quantize(CAPITAL_RESULT_QUANTUM, rounding=ROUND_HALF_UP) 

690 

691 

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" 

695 

696 

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 

712 

713 

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()