Coverage for backend/django/Economics/formulas/builders/capital.py: 76%

238 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-06-23 21:51 +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(mapping, driver=None, existing_line=None) -> GeneratedCapitalLineFormula: 

132 """Compose curve, index, Lang-factor, and contingency arithmetic once.""" 

133 del driver, existing_line 

134 curve = mapping.cost_curve 

135 if curve is None: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 raise FormulaError( 

137 "missing_cost_curve", 

138 "Generated capital line formulas require a selected cost curve.", 

139 context={"costable_item_id": mapping.costable_item_id}, 

140 ) 

141 curve_formula = ( 

142 build_cost_curve_formula(curve) 

143 if curve.evaluation_kind == CostCurveEvaluationKind.EXPRESSION 

144 else _placeholder_discrete_curve_formula(curve) 

145 ) 

146 index_adjustment = capital_index_adjustment(mapping.costable_item.study, curve) 

147 lang_factor_resolution = resolve_lang_factor(mapping) 

148 applies_lang_factor = ( 

149 curve.cost_basis == CostBasis.PURCHASE 

150 and lang_factor_resolution.effective_value is not None 

151 ) 

152 contingency_percent = contingency_percent_for_study(mapping.costable_item.study) or Decimal("0") 

153 contingency_factor = Decimal("1") + (contingency_percent / Decimal("100")) 

154 

155 expression = curve_formula.expression 

156 steps = [ 

157 FormulaStep( 

158 kind="base_curve_cost", 

159 label="Curve base cost", 

160 expression=curve_formula.audit_payload()["formula"], 

161 unit=curve.output_unit, 

162 ) 

163 ] 

164 expression = _multiply_factor( 

165 expression, 

166 index_adjustment.factor, 

167 steps=steps, 

168 kind="index_adjustment", 

169 label="CPI/index adjustment", 

170 detail=index_adjustment.detail, 

171 ) 

172 if applies_lang_factor: 172 ↛ 181line 172 didn't jump to line 181 because the condition on line 172 was always true

173 expression = _multiply_factor( 

174 expression, 

175 lang_factor_resolution.effective_value, 

176 steps=steps, 

177 kind="lang_factor", 

178 label="Lang factor", 

179 detail=lang_factor_resolution.source, 

180 ) 

181 expression = _multiply_factor( 

182 expression, 

183 contingency_factor, 

184 steps=steps, 

185 kind="contingency", 

186 label="Contingency", 

187 percent=contingency_percent, 

188 ) 

189 return GeneratedCapitalLineFormula( 

190 formula=EconomicsFormula( 

191 key=f"generated_capital_line:{mapping.pk}", 

192 expression=expression, 

193 unit=curve.output_unit, 

194 inputs=curve_formula.inputs, 

195 steps=tuple(steps), 

196 ), 

197 index_adjustment=index_adjustment, 

198 lang_factor=lang_factor_resolution.effective_value, 

199 lang_factor_source=lang_factor_resolution.source, 

200 applies_lang_factor=applies_lang_factor, 

201 contingency_percent=contingency_percent, 

202 contingency_factor=contingency_factor, 

203 ) 

204 

205 

206def _formula_input_specs(curve): 

207 from Economics.costing.cost_curves.driver_specs import parse_required_driver_specs 

208 

209 try: 

210 specs = parse_required_driver_specs(curve.required_driver_specs) 

211 except ValueError as exc: 

212 raise FormulaError( 

213 "invalid_cost_curve_driver_specs", 

214 str(exc), 

215 context={"curve_key": curve.curve_key}, 

216 ) from exc 

217 return tuple(spec for spec in specs if spec.role == "formula_input") 

218 

219 

220def _placeholder_discrete_curve_formula(curve) -> EconomicsFormula: 

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

222 

223 Generated capital recalculation evaluates discrete candidates in 

224 ``evaluate_cost_curve``. This placeholder keeps index/Lang/contingency 

225 composition centralized without pretending there is a top-level expression. 

226 """ 

227 return EconomicsFormula( 

228 key=f"cost_curve:{curve.pk or curve.curve_key}", 

229 expression=decimal_to_sympy(Decimal("1")), 

230 unit=curve.output_unit, 

231 inputs=(), 

232 solve_visible=False, 

233 ) 

234 

235 

236def build_generated_unit_capex_subtotal_formula(study) -> BoundCapitalFormula: 

237 """Build the generated unit-operation CAPEX subtotal formula.""" 

238 terms: list[sympy.Expr] = [] 

239 blocked_children: list[dict[str, str]] = [] 

240 for line in ( 

241 study.capital_lines.filter( 

242 included=True, 

243 source=GENERATED_CAPITAL_LINE_SOURCE, 

244 ) 

245 .exclude(costable_item__simulation_object__is_deleted=True) 

246 .order_by("pk") 

247 ): 

248 if line.amount is None and line.cost_curve_id is not None: 

249 blocked_children.append( 

250 { 

251 "key": f"capital_line:{line.pk}", 

252 "reason": "included generated capital line has no amount", 

253 } 

254 ) 

255 continue 

256 if line.amount is None: 

257 continue 

258 terms.append(decimal_to_sympy(line.amount)) 

259 return BoundCapitalFormula( 

260 formula=EconomicsFormula( 

261 key="generated_unit_capex_subtotal", 

262 expression=_sum_expressions(terms), 

263 unit=_study_currency(study), 

264 inputs=(), 

265 steps=( 

266 FormulaStep( 

267 kind="generated_capital_line_sum", 

268 label="Generated unit CAPEX subtotal", 

269 expression="sum(included generated capital lines)", 

270 amount=None, 

271 unit=_study_currency(study), 

272 ), 

273 ), 

274 missing_child_policy="strict_included_children", 

275 blocked_children=tuple(blocked_children), 

276 blocked_reason="Included generated capital lines are missing amounts." 

277 if blocked_children 

278 else "", 

279 ), 

280 bindings={}, 

281 ) 

282 

283 

284def build_custom_capital_line_formula(line, *, base_capex: Decimal) -> BoundCapitalFormula: 

285 """Build a formula for a fixed or percentage custom capital line.""" 

286 currency = line.currency or _study_currency(line.study) 

287 if line.calculation_basis != CapitalLineBasis.BASE_CAPEX_PERCENT: 

288 if line.amount is None: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 raise FormulaError( 

290 "missing_custom_capital_amount", 

291 "Fixed custom capital lines require an amount.", 

292 context={"capital_line_id": line.pk}, 

293 ) 

294 return BoundCapitalFormula( 

295 formula=EconomicsFormula( 

296 key=f"custom_capital_line:{line.pk or 'unsaved'}", 

297 expression=decimal_to_sympy(line.amount), 

298 unit=currency, 

299 inputs=(), 

300 steps=( 

301 FormulaStep( 

302 kind="fixed_capital_amount", 

303 label="Fixed capital amount", 

304 expression=str(line.amount), 

305 amount=line.amount, 

306 unit=currency, 

307 ), 

308 ), 

309 ), 

310 bindings={}, 

311 ) 

312 if line.basis_percent is None: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true

313 raise FormulaError( 

314 "missing_custom_capital_percent", 

315 "Percentage custom capital lines require a basis percent.", 

316 context={"capital_line_id": line.pk}, 

317 ) 

318 basis_symbol = sympy.Symbol(CUSTOM_CAPEX_PERCENTAGE_BASIS) 

319 percent_factor = line.basis_percent / Decimal("100") 

320 return BoundCapitalFormula( 

321 formula=EconomicsFormula( 

322 key=f"custom_capital_line:{line.pk or 'unsaved'}", 

323 expression=sympy.Mul(basis_symbol, decimal_to_sympy(percent_factor), evaluate=False), 

324 unit=currency, 

325 inputs=( 

326 FormulaInput( 

327 key=CUSTOM_CAPEX_PERCENTAGE_BASIS, 

328 label="Custom CAPEX percentage basis", 

329 unit=currency, 

330 ), 

331 ), 

332 steps=( 

333 FormulaStep( 

334 kind="base_capex_percent", 

335 label="Base CAPEX percentage", 

336 expression=str(percent_factor), 

337 amount=percent_factor, 

338 unit="factor", 

339 ), 

340 ), 

341 ), 

342 bindings={CUSTOM_CAPEX_PERCENTAGE_BASIS: base_capex}, 

343 ) 

344 

345 

346def build_custom_capital_total_formula(study, *, base_capex: Decimal | None = None) -> BoundCapitalFormula: 

347 """Build the included custom capital total formula.""" 

348 generated_subtotal = base_capex 

349 if generated_subtotal is None: 

350 generated_subtotal_formula = build_generated_unit_capex_subtotal_formula(study) 

351 generated_subtotal = generated_subtotal_formula.evaluate() 

352 if generated_subtotal is None: 

353 return BoundCapitalFormula( 

354 formula=EconomicsFormula( 

355 key="custom_capital_total", 

356 expression=decimal_to_sympy(Decimal("0")), 

357 unit=_study_currency(study), 

358 inputs=(), 

359 missing_child_policy="strict_included_children", 

360 blocked_children=( 

361 { 

362 "key": generated_subtotal_formula.formula.key, 

363 "reason": generated_subtotal_formula.formula.blocked_reason, 

364 }, 

365 ), 

366 blocked_reason="Custom capital total requires generated unit CAPEX subtotal.", 

367 ), 

368 bindings={}, 

369 ) 

370 terms: list[sympy.Expr] = [] 

371 bindings: dict[str, Decimal] = {} 

372 inputs: list[FormulaInput] = [] 

373 blocked_children: list[dict[str, str]] = [] 

374 for line in study.capital_lines.filter(included=True).exclude(source=GENERATED_CAPITAL_LINE_SOURCE).order_by("pk"): 

375 try: 

376 line_formula = build_custom_capital_line_formula(line, base_capex=generated_subtotal) 

377 amount = line_formula.evaluate() 

378 except FormulaError as exc: 

379 amount = None 

380 blocked_children.append({"key": f"capital_line:{line.pk}", "reason": exc.message}) 

381 if amount is None: 

382 continue 

383 if line.calculation_basis == CapitalLineBasis.BASE_CAPEX_PERCENT: 

384 bindings[CUSTOM_CAPEX_PERCENTAGE_BASIS] = generated_subtotal 

385 if not any(formula_input.key == CUSTOM_CAPEX_PERCENTAGE_BASIS for formula_input in inputs): 

386 inputs.extend(line_formula.formula.inputs) 

387 terms.append(line_formula.formula.expression) 

388 return BoundCapitalFormula( 

389 formula=EconomicsFormula( 

390 key="custom_capital_total", 

391 expression=_sum_expressions(terms), 

392 unit=_study_currency(study), 

393 inputs=tuple(inputs), 

394 steps=( 

395 FormulaStep( 

396 kind="custom_capital_total", 

397 label="Custom capital total", 

398 expression="sum(included custom capital line formulas)", 

399 unit=_study_currency(study), 

400 ), 

401 ), 

402 missing_child_policy="strict_included_children", 

403 blocked_children=tuple(blocked_children), 

404 blocked_reason="Included custom capital lines are blocked." if blocked_children else "", 

405 ), 

406 bindings=bindings, 

407 ) 

408 

409 

410def build_peak_demand_formula(study) -> BoundCapitalFormula: 

411 """Build the peak-demand capacity formula used for electrical upgrade CAPEX.""" 

412 peak_demand_basis = derive_peak_demand_basis(study) 

413 quantity_kw = peak_demand_basis.quantity_kw or Decimal("0") 

414 return BoundCapitalFormula( 

415 formula=EconomicsFormula( 

416 key="peak_demand_capacity", 

417 expression=decimal_to_sympy(quantity_kw), 

418 unit=peak_demand_basis.unit, 

419 inputs=(), 

420 steps=( 

421 FormulaStep( 

422 kind="peak_demand_sum", 

423 label="Peak demand capacity", 

424 expression="sum(included capital line peak demand)", 

425 amount=quantity_kw, 

426 unit=peak_demand_basis.unit, 

427 ), 

428 ), 

429 missing_child_policy="omitted_before_formula_when_not_applicable", 

430 ), 

431 bindings={}, 

432 ) 

433 

434 

435def build_electrical_upgrade_formula(study) -> BoundCapitalFormula: 

436 """Build the electrical-upgrade CAPEX formula from power capacity and rate.""" 

437 assumptions = get_settings_profile(study) 

438 if assumptions is None: 

439 rate_amount = Decimal("0") 

440 currency = "NZD" 

441 else: 

442 rate_amount = assumptions.electrical_upgrade_rate_amount or Decimal("0") 

443 currency = assumptions.currency or "NZD" 

444 peak_symbol = sympy.Symbol(PEAK_DEMAND_BASIS_KW) 

445 rate_symbol = sympy.Symbol(ELECTRICAL_UPGRADE_RATE) 

446 return BoundCapitalFormula( 

447 formula=EconomicsFormula( 

448 key="electrical_upgrade_capex", 

449 expression=sympy.Mul(peak_symbol, rate_symbol, evaluate=False), 

450 unit=currency, 

451 inputs=( 

452 FormulaInput( 

453 key=PEAK_DEMAND_BASIS_KW, 

454 label="Peak demand capacity", 

455 unit="kW", 

456 ), 

457 FormulaInput( 

458 key=ELECTRICAL_UPGRADE_RATE, 

459 label="Electrical upgrade rate", 

460 unit=f"{currency}/kW", 

461 ), 

462 ), 

463 steps=( 

464 FormulaStep( 

465 kind="electrical_upgrade_rate", 

466 label="Electrical upgrade rate", 

467 expression=str(rate_amount), 

468 amount=rate_amount, 

469 unit=f"{currency}/kW", 

470 ), 

471 ), 

472 ), 

473 bindings={ 

474 PEAK_DEMAND_BASIS_KW: build_peak_demand_formula(study).evaluate() or Decimal("0"), 

475 ELECTRICAL_UPGRADE_RATE: rate_amount, 

476 }, 

477 ) 

478 

479 

480def build_target_total_capex_formula(study) -> BoundCapitalFormula: 

481 """Build the target total CAPEX formula from included capital-line formulas.""" 

482 blocked_children: list[dict[str, str]] = [] 

483 terms: list[sympy.Expr] = [] 

484 inputs: list[FormulaInput] = [] 

485 bindings: dict[str, Decimal] = {} 

486 generated_subtotal = build_generated_unit_capex_subtotal_formula(study).evaluate() or Decimal("0") 

487 for line in study.capital_lines.filter(included=True).order_by("pk"): 

488 amount = _capital_line_amount_for_total(line, generated_subtotal=generated_subtotal) 

489 if amount is None: 

490 if line.cost_curve_id is not None: 

491 blocked_children.append( 

492 { 

493 "key": f"capital_line:{line.pk}", 

494 "reason": "included capital line has no amount", 

495 } 

496 ) 

497 continue 

498 symbol_key = capital_line_input_key(line.pk) 

499 terms.append(sympy.Symbol(symbol_key)) 

500 inputs.append( 

501 FormulaInput( 

502 key=symbol_key, 

503 label=line.label, 

504 unit=_study_currency(study), 

505 ) 

506 ) 

507 bindings[symbol_key] = amount 

508 electrical_formula = build_electrical_upgrade_formula(study) 

509 electrical_upgrade = electrical_formula.evaluate() 

510 if electrical_upgrade is None: 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true

511 blocked_children.append({"key": electrical_formula.formula.key, "reason": electrical_formula.formula.blocked_reason}) 

512 electrical_upgrade = Decimal("0") 

513 terms.append(sympy.Symbol(ELECTRICAL_UPGRADE_CAPEX)) 

514 inputs.append( 

515 FormulaInput( 

516 key=ELECTRICAL_UPGRADE_CAPEX, 

517 label="Electrical upgrade CAPEX", 

518 unit=_study_currency(study), 

519 ) 

520 ) 

521 bindings[ELECTRICAL_UPGRADE_CAPEX] = electrical_upgrade 

522 return BoundCapitalFormula( 

523 formula=EconomicsFormula( 

524 key="target_total_capex", 

525 expression=_sum_expressions(terms), 

526 unit=_study_currency(study), 

527 inputs=tuple(inputs), 

528 steps=( 

529 FormulaStep( 

530 kind="target_total_capex", 

531 label="Target total CAPEX", 

532 expression="generated subtotal + custom capital + electrical upgrade", 

533 unit=_study_currency(study), 

534 ), 

535 ), 

536 missing_child_policy="strict_included_children", 

537 blocked_children=tuple(blocked_children), 

538 blocked_reason="Target total CAPEX has blocked children." if blocked_children else "", 

539 ), 

540 bindings=bindings, 

541 ) 

542 

543 

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

545 return f"capital_line_{line_id}" 

546 

547 

548def _capital_line_amount_for_total(line, *, generated_subtotal: Decimal) -> Decimal | None: 

549 if line.source == GENERATED_CAPITAL_LINE_SOURCE: 

550 return line.amount 

551 try: 

552 return build_custom_capital_line_formula(line, base_capex=generated_subtotal).evaluate() 

553 except FormulaError: 

554 return None 

555 

556 

557def capital_index_adjustment(study, curve) -> CapitalIndexAdjustment: 

558 """Resolve the capital index factor applied to a curve-backed cost.""" 

559 assumptions = get_settings_profile(study) 

560 if assumptions is None or assumptions.capital_index_series_id is None: 

561 return CapitalIndexAdjustment( 

562 factor=Decimal("1"), 

563 detail="No capital index series selected", 

564 ) 

565 target_value = _capital_index_target_value(assumptions) 

566 if target_value is None: 

567 raise FormulaError( 

568 "missing_capital_index_target_value", 

569 "Selected capital index series has no target value for the study basis date.", 

570 context={ 

571 "study_id": study.pk, 

572 "series_id": assumptions.capital_index_series_id, 

573 "basis_date": None if assumptions.basis_date is None else assumptions.basis_date.isoformat(), 

574 }, 

575 ) 

576 basis_value, basis_label = _capital_index_basis_value(assumptions, curve) 

577 if basis_value in (None, Decimal("0")): 577 ↛ 578line 577 didn't jump to line 578 because the condition on line 577 was never true

578 raise FormulaError( 

579 "missing_capital_index_basis_value", 

580 "Selected capital index series has no curve basis value.", 

581 context={ 

582 "curve_key": curve.curve_key, 

583 "series_id": assumptions.capital_index_series_id, 

584 "basis": basis_label, 

585 }, 

586 ) 

587 factor = target_value.value / basis_value 

588 return CapitalIndexAdjustment( 

589 factor=factor, 

590 detail=( 

591 f"{target_value.series.name}: {target_value.period} " 

592 f"{target_value.value} / {basis_label} {basis_value}" 

593 ), 

594 ) 

595 

596 

597def contingency_percent_for_study(study) -> Decimal | None: 

598 """Return the study contingency percentage applied to generated capital costs.""" 

599 assumptions = get_settings_profile(study) 

600 if assumptions is None or assumptions.contingency_percent is None: 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true

601 return None 

602 return assumptions.contingency_percent 

603 

604 

605def _multiply_factor( 

606 expression: sympy.Expr, 

607 factor: Decimal, 

608 *, 

609 steps: list[FormulaStep], 

610 kind: str, 

611 label: str, 

612 detail: str = "", 

613 percent: Decimal | None = None, 

614) -> sympy.Expr: 

615 if factor == Decimal("1"): 

616 return expression 

617 steps.append( 

618 FormulaStep( 

619 kind=kind, 

620 label=label, 

621 expression=str(factor), 

622 amount=factor, 

623 unit="factor", 

624 ) 

625 ) 

626 return sympy.Mul(expression, decimal_to_sympy(factor), evaluate=False) 

627 

628 

629def _sum_expressions(terms: list[sympy.Expr]) -> sympy.Expr: 

630 if not terms: 

631 return decimal_to_sympy(Decimal("0")) 

632 if len(terms) == 1: 

633 return terms[0] 

634 return sympy.Add(*terms, evaluate=False) 

635 

636 

637def _study_currency(study) -> str: 

638 assumptions = get_settings_profile(study) 

639 if assumptions is None: 

640 return "NZD" 

641 return assumptions.currency or "NZD" 

642 

643 

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

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

646 

647 

648def _decimal_literal(value: Decimal | int | str) -> str: 

649 decimal_value = Decimal(str(value)) 

650 return format(decimal_value, "f").rstrip("0").rstrip(".") or "0" 

651 

652 

653def _capital_index_basis_value(assumptions: EconomicsSettingsProfile, curve) -> tuple[Decimal | None, str]: 

654 """Resolve the curve-side index value used as the escalation denominator.""" 

655 if curve.basis_index_value is not None: 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true

656 basis_label = curve.basis_date.isoformat() if curve.basis_date else "curve basis" 

657 return curve.basis_index_value, basis_label 

658 if curve.basis_date is None: 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

659 return None, "curve basis" 

660 basis_value = ( 

661 assumptions.capital_index_series.values.filter(period_date__lte=curve.basis_date) 

662 .order_by("-period_date", "-pk") 

663 .first() 

664 ) 

665 if basis_value is None: 665 ↛ 666line 665 didn't jump to line 666 because the condition on line 665 was never true

666 return None, curve.basis_date.isoformat() 

667 return basis_value.value, basis_value.period 

668 

669 

670def _capital_index_target_value(assumptions: EconomicsSettingsProfile) -> CostIndexValue | None: 

671 """Resolve the latest selected index value at or before the study basis date.""" 

672 values = assumptions.capital_index_series.values.order_by("-period_date", "-pk") 

673 if assumptions.basis_date is not None: 673 ↛ 675line 673 didn't jump to line 675 because the condition on line 673 was always true

674 values = values.filter(period_date__lte=assumptions.basis_date) 

675 return values.first()