Coverage for backend/django/Economics/formulas/builders/native_property_formulas.py: 77%

316 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, DecimalException, InvalidOperation 

5import re 

6from typing import Sequence, TypeAlias, TypedDict 

7 

8from core.auxiliary.formula_limits import validate_formula_length 

9from core.auxiliary.models.PropertyInfo import PropertyInfo 

10from core.auxiliary.models.PropertyValue import PropertyValue 

11from django.core.exceptions import ObjectDoesNotExist 

12from pydantic import ValidationError 

13 

14from Economics.costing.models import CapitalCostLine, CostCurve, EquipmentMapping, OperatingCostLine 

15from Economics.shared.choices import CostBasis, CostCurveEvaluationKind, OperatingLineCategory 

16from Economics.studies.models import EconomicsStudy 

17from Economics.costing.capital.capital_line_sources import GENERATED_CAPITAL_LINE_SOURCE 

18from Economics.costing.cost_curves.evaluation import ( 

19 CostCurveEvaluationError, 

20 NormalizedCurveInputValue, 

21 normalize_economics_unit_notation, 

22 select_discrete_capacity_variant, 

23 validate_discrete_variant_selectors, 

24) 

25from Economics.costing.cost_curves.driver_specs import ( 

26 CapitalCostDriverInput, 

27 CostCurveDiscreteVariant, 

28 CostCurveDriverSpec, 

29 parse_discrete_variants, 

30 parse_required_driver_specs, 

31) 

32from Economics.formulas.builders.capital import ( 

33 build_cost_curve_variant_formula, 

34 build_generated_capital_line_formula, 

35 build_generated_unit_capex_subtotal_formula, 

36 capital_index_adjustment, 

37 contingency_percent_for_study, 

38) 

39from Economics.formulas.engine.core import FormulaError 

40from Economics.formulas.builders.operating import ( 

41 build_annual_operating_expense_formula, 

42 build_operating_line_formula, 

43 operating_line_is_revenue, 

44 render_operating_line_property_formula, 

45) 

46from core.auxiliary.formula_units import formula_unit_expression 

47from Economics.costing.capital.lang_factors import resolve_lang_factor 

48from Economics.settings_profiles.services.settings_profiles import get_settings_profile 

49from Economics.costing.line_properties.references import CAPITAL_LINE_KIND, OPERATING_LINE_KIND, line_property_reference 

50from idaes_factory.unit_conversion.unit_conversion import can_convert, convert_value 

51from pint.errors import PintError 

52 

53PROPERTY_MENTION_PATTERN = re.compile(r"^@\[[^\]]+\]\(prop\d+\)$") 

54 

55 

56SelectorRawInputValue: TypeAlias = Decimal | int | float | str | None 

57 

58 

59class SelectorRawValue(TypedDict): 

60 value: SelectorRawInputValue 

61 unit: str 

62 

63 

64@dataclass(frozen=True) 

65class NativePropertyExpression: 

66 """Formula plus optimisation visibility diagnostics for a native property.""" 

67 

68 formula: str 

69 solve_visible: bool 

70 blocked_reason: str = "" 

71 value: Decimal | None = None 

72 formula_record_id: int | None = None 

73 

74 

75def base_capital_cost_property_expression(study: EconomicsStudy) -> NativePropertyExpression: 

76 """Render the solve-visible generated-unit CAPEX subtotal property formula.""" 

77 

78 terms: list[str] = [] 

79 for line in _generated_capital_lines(study): 

80 line_reference = line_property_reference(study, line_kind=CAPITAL_LINE_KIND, line_id=line.pk) 

81 if line_reference: 

82 terms.append(line_reference) 

83 continue 

84 rendered = generated_capital_line_property_expression(line) 

85 if not rendered.solve_visible: 85 ↛ 87line 85 didn't jump to line 87 because the condition on line 85 was always true

86 return rendered 

87 if rendered.formula: 

88 terms.append(rendered.formula) 

89 result = _sum_terms(terms, unit=_study_currency(study)) 

90 subtotal_formula = build_generated_unit_capex_subtotal_formula(study) 

91 subtotal_value = subtotal_formula.evaluate() 

92 if subtotal_value is None: 

93 return NativePropertyExpression( 

94 result.formula, 

95 False, 

96 subtotal_formula.formula.blocked_reason, 

97 ) 

98 return NativePropertyExpression( 

99 result.formula, 

100 result.solve_visible, 

101 result.blocked_reason, 

102 subtotal_value.quantize(Decimal("0.0001")), 

103 ) 

104 

105 

106def annual_operating_expense_property_expression(study: EconomicsStudy) -> NativePropertyExpression: 

107 result = annual_operating_total_property_expression(study, include_revenue=False) 

108 return NativePropertyExpression( 

109 result.formula, 

110 result.solve_visible, 

111 result.blocked_reason, 

112 build_annual_operating_expense_formula(study).evaluate(), 

113 ) 

114 

115 

116def annual_operating_total_property_expression( 

117 study: EconomicsStudy, 

118 *, 

119 include_revenue: bool, 

120) -> NativePropertyExpression: 

121 """Render annual operating totals from their included operating-line formulas.""" 

122 

123 terms: list[str] = [] 

124 has_matching_line = False 

125 for line in ( 

126 study.operating_lines.filter(included=True) 

127 .select_related("source_property_info", "source_default_rate") 

128 .order_by("pk") 

129 ): 

130 if operating_line_is_revenue(line) != include_revenue: 

131 continue 

132 has_matching_line = True 

133 line_reference = line_property_reference(study, line_kind=OPERATING_LINE_KIND, line_id=line.pk) 

134 if line_reference: 

135 terms.append(line_reference) 

136 continue 

137 rendered = operating_line_property_expression(line, study=study) 

138 if not rendered.solve_visible: 

139 return rendered 

140 if rendered.formula: 140 ↛ 125line 140 didn't jump to line 125 because the condition on line 140 was always true

141 terms.append(rendered.formula) 

142 if not has_matching_line: 

143 label = "Annual revenue" if include_revenue else "Annual operating expense" 

144 return NativePropertyExpression("", False, f"{label} has no included operating lines.") 

145 return _sum_terms(terms, unit=f"{_study_currency(study)}/year") 

146 

147 

148def _generated_capital_lines(study: EconomicsStudy): 

149 return ( 

150 study.capital_lines.filter( 

151 included=True, 

152 source=GENERATED_CAPITAL_LINE_SOURCE, 

153 ) 

154 .exclude(costable_item__simulation_object__is_deleted=True) 

155 .select_related( 

156 "cost_curve", 

157 "costable_item", 

158 "costable_item__equipment_mapping", 

159 "costable_item__equipment_mapping__cost_curve", 

160 "costable_item__cost_driver", 

161 "costable_item__cost_driver__property_info", 

162 ) 

163 .order_by("pk") 

164 ) 

165 

166 

167def generated_capital_line_property_expression(line: CapitalCostLine) -> NativePropertyExpression: 

168 if line.cost_curve_id is None or line.cost_curve is None: 

169 return NativePropertyExpression("", False, f"`{line.label}` has no selected cost curve.") 

170 if line.costable_item_id is None or line.costable_item is None: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

171 return NativePropertyExpression("", False, f"`{line.label}` has no generated costable item.") 

172 try: 

173 mapping = line.costable_item.equipment_mapping 

174 except ObjectDoesNotExist: 

175 return NativePropertyExpression("", False, f"`{line.label}` has no equipment mapping.") 

176 if mapping.cost_curve_id is None or mapping.cost_curve is None: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 return NativePropertyExpression("", False, f"`{line.label}` has no selected cost curve.") 

178 curve = mapping.cost_curve 

179 try: 

180 specs = parse_required_driver_specs(curve.required_driver_specs) 

181 except ValueError as exc: 

182 return NativePropertyExpression( 

183 "", 

184 False, 

185 f"`{line.label}` has invalid cost-curve inputs: {exc}", 

186 ) 

187 formula_specs = tuple(spec for spec in specs if spec.role == "formula_input") 

188 try: 

189 render_bindings = { 

190 spec.variable_symbol: _render_driver_input_formula(line=line, spec=spec) 

191 for spec in formula_specs 

192 } 

193 output_unit_expression = formula_unit_expression(curve.output_unit) 

194 if output_unit_expression is None: 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

195 return NativePropertyExpression("", False, f"`{line.label}` has unsupported output unit `{curve.output_unit}`.") 

196 if curve.evaluation_kind == CostCurveEvaluationKind.DISCRETE_FAMILY: 

197 selector_specs = tuple(spec for spec in specs if spec.role == "discrete_selector") 

198 for selector_spec in selector_specs: 

199 _render_driver_input_formula(line=line, spec=selector_spec) 

200 blocked_reason = _property_backed_discrete_selector_blocked_reason( 

201 line=line, 

202 selector_specs=selector_specs, 

203 ) 

204 if blocked_reason: 

205 return NativePropertyExpression("", False, f"`{line.label}` {blocked_reason}") 

206 expression_formula = _render_discrete_family_generated_formula( 

207 line=line, 

208 mapping=mapping, 

209 curve=curve, 

210 formula_specs=formula_specs, 

211 selector_specs=selector_specs, 

212 render_bindings=render_bindings, 

213 output_unit_expression=output_unit_expression, 

214 ) 

215 return NativePropertyExpression( 

216 validate_formula_length(expression_formula), 

217 True, 

218 value=line.amount, 

219 ) 

220 generated_formula = build_generated_capital_line_formula( 

221 mapping, 

222 existing_line=line, 

223 ) 

224 expression_formula = generated_formula.formula.render_property_formula( 

225 render_bindings 

226 ) 

227 return NativePropertyExpression( 

228 validate_formula_length(f"(({expression_formula}) * ({output_unit_expression}))"), 

229 True, 

230 value=line.amount, 

231 ) 

232 except FormulaError as exc: 

233 return NativePropertyExpression("", False, f"`{line.label}` {exc.message}") 

234 

235 

236def _render_discrete_family_generated_formula( 

237 *, 

238 line: CapitalCostLine, 

239 mapping: EquipmentMapping, 

240 curve: CostCurve, 

241 formula_specs: Sequence[CostCurveDriverSpec], 

242 selector_specs: Sequence[CostCurveDriverSpec], 

243 render_bindings: dict[str, str], 

244 output_unit_expression: str, 

245) -> str: 

246 variants = _parse_discrete_variants_for_formula(line=line, curve=curve) 

247 variant = _select_capacity_variant_for_line( 

248 line=line, 

249 curve=curve, 

250 variants=variants, 

251 selector_specs=selector_specs, 

252 ) 

253 return _render_discrete_variant_generated_formula( 

254 mapping=mapping, 

255 curve=curve, 

256 variant=variant, 

257 formula_specs=formula_specs, 

258 render_bindings=render_bindings, 

259 output_unit_expression=output_unit_expression, 

260 ) 

261 

262 

263def _parse_discrete_variants_for_formula( 

264 *, 

265 line: CapitalCostLine, 

266 curve: CostCurve, 

267) -> tuple[CostCurveDiscreteVariant, ...]: 

268 try: 

269 variants = parse_discrete_variants(curve.discrete_variants) 

270 except ValueError as exc: 

271 raise FormulaError( 

272 "invalid_discrete_variants", 

273 f"has invalid discrete cost-curve variants: {exc}", 

274 context={"line_id": line.pk, "curve_key": curve.curve_key}, 

275 ) from exc 

276 if not variants: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 raise FormulaError( 

278 "missing_discrete_variants", 

279 "has no discrete cost-curve variants.", 

280 context={"line_id": line.pk, "curve_key": curve.curve_key}, 

281 ) 

282 return variants 

283 

284 

285def _select_capacity_variant_for_line( 

286 *, 

287 line: CapitalCostLine, 

288 curve: CostCurve, 

289 variants: Sequence[CostCurveDiscreteVariant], 

290 selector_specs: Sequence[CostCurveDriverSpec], 

291) -> CostCurveDiscreteVariant: 

292 try: 

293 validate_discrete_variant_selectors( 

294 curve=curve, 

295 variants=variants, 

296 selector_keys={spec.key for spec in selector_specs}, 

297 ) 

298 return select_discrete_capacity_variant( 

299 curve=curve, 

300 variants=variants, 

301 selector_specs=selector_specs, 

302 normalized_inputs=_selector_normalized_inputs_for_line( 

303 line=line, 

304 selector_specs=selector_specs, 

305 ), 

306 ) 

307 except CostCurveEvaluationError as exc: 

308 raise FormulaError( 

309 exc.code, 

310 f"cannot select a discrete cost-curve variant: {exc.message}", 

311 context={"line_id": line.pk, "curve_key": curve.curve_key, **exc.context}, 

312 ) from exc 

313 

314 

315def _property_backed_discrete_selector_blocked_reason( 

316 *, 

317 line: CapitalCostLine, 

318 selector_specs: Sequence[CostCurveDriverSpec], 

319) -> str: 

320 for spec in selector_specs: 

321 driver_input = _selector_driver_input(line=line, spec=spec) 

322 if driver_input.source == "property": 

323 return ( 

324 f"uses `{spec.label}` as a property-backed discrete capacity selector; " 

325 "set a manual design capacity to keep the generated formula solve-visible." 

326 ) 

327 return "" 

328 

329 

330def _selector_normalized_inputs_for_line( 

331 *, 

332 line: CapitalCostLine, 

333 selector_specs: Sequence[CostCurveDriverSpec], 

334) -> dict[str, NormalizedCurveInputValue]: 

335 return { 

336 spec.key: { 

337 "value": _selector_decimal_value(line=line, spec=spec), 

338 } 

339 for spec in selector_specs 

340 } 

341 

342 

343def _selector_decimal_value(*, line: CapitalCostLine, spec: CostCurveDriverSpec) -> Decimal: 

344 driver_input = _selector_driver_input(line=line, spec=spec) 

345 raw = _selector_raw_value(line=line, spec=spec, driver_input=driver_input) 

346 value = _selector_decimal_from_raw(line=line, spec=spec, raw_value=raw["value"]) 

347 return _selector_value_in_spec_unit(line=line, spec=spec, value=value, source_unit=raw["unit"]) 

348 

349 

350def _selector_driver_input(*, line: CapitalCostLine, spec: CostCurveDriverSpec) -> CapitalCostDriverInput: 

351 driver_inputs = line.driver_inputs if isinstance(line.driver_inputs, dict) else {} 

352 try: 

353 return CapitalCostDriverInput.model_validate(driver_inputs.get(spec.key)) 

354 except ValidationError as exc: 

355 raise FormulaError( 

356 "missing_driver_input", 

357 f"has no selected capacity for `{spec.label}`.", 

358 context={"line_id": line.pk, "input_key": spec.key}, 

359 ) from exc 

360 

361 

362def _selector_raw_value( 

363 *, 

364 line: CapitalCostLine, 

365 spec: CostCurveDriverSpec, 

366 driver_input: CapitalCostDriverInput, 

367) -> SelectorRawValue: 

368 source_unit = normalize_economics_unit_notation(driver_input.unit or spec.unit) 

369 if driver_input.source == "property": 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true

370 property_info = PropertyInfo.objects.filter( 

371 pk=driver_input.property_info, 

372 flowsheet_id=line.flowsheet_id, 

373 ).first() 

374 if property_info is None: 

375 raise FormulaError( 

376 "missing_driver_input_property", 

377 f"has no selected capacity property for `{spec.label}`.", 

378 context={"line_id": line.pk, "input_key": spec.key}, 

379 ) 

380 return { 

381 "value": property_info.get_value(), 

382 "unit": normalize_economics_unit_notation(property_info.unit or source_unit), 

383 } 

384 if driver_input.source == "manual": 384 ↛ 386line 384 didn't jump to line 386 because the condition on line 384 was always true

385 return {"value": driver_input.manual_value, "unit": source_unit} 

386 raise FormulaError( 

387 "missing_driver_input", 

388 f"has no selected capacity source for `{spec.label}`.", 

389 context={"line_id": line.pk, "input_key": spec.key}, 

390 ) 

391 

392 

393def _selector_decimal_from_raw( 

394 *, 

395 line: CapitalCostLine, 

396 spec: CostCurveDriverSpec, 

397 raw_value: SelectorRawInputValue, 

398) -> Decimal: 

399 if raw_value in (None, ""): 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true

400 raise FormulaError( 

401 "missing_driver_input", 

402 f"has no selected capacity value for `{spec.label}`.", 

403 context={"line_id": line.pk, "input_key": spec.key}, 

404 ) 

405 try: 

406 value = Decimal(str(raw_value)) 

407 except (InvalidOperation, TypeError, ValueError) as exc: 

408 raise FormulaError( 

409 "invalid_driver_input", 

410 f"has a non-numeric selected capacity for `{spec.label}`.", 

411 context={"line_id": line.pk, "input_key": spec.key}, 

412 ) from exc 

413 if not value.is_finite(): 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true

414 raise FormulaError( 

415 "invalid_driver_input", 

416 f"has a non-finite selected capacity for `{spec.label}`.", 

417 context={"line_id": line.pk, "input_key": spec.key}, 

418 ) 

419 return value 

420 

421 

422def _selector_value_in_spec_unit( 

423 *, 

424 line: CapitalCostLine, 

425 spec: CostCurveDriverSpec, 

426 value: Decimal, 

427 source_unit: str, 

428) -> Decimal: 

429 source_unit = normalize_economics_unit_notation(source_unit) 

430 target_unit = normalize_economics_unit_notation(spec.unit) 

431 if source_unit == target_unit: 431 ↛ 433line 431 didn't jump to line 433 because the condition on line 431 was always true

432 return value 

433 if not _unit_conversion_supported(source_unit, target_unit): 

434 raise FormulaError( 

435 "incompatible_driver_input_unit", 

436 f"needs `{source_unit}` to `{target_unit}` conversion before it can select a capacity variant.", 

437 context={"line_id": line.pk, "input_key": spec.key}, 

438 ) 

439 try: 

440 converted = Decimal(str(convert_value(value, from_unit=source_unit, to_unit=target_unit))) 

441 except (DecimalException, PintError, TypeError, ValueError) as exc: 

442 raise FormulaError( 

443 "invalid_driver_input_unit", 

444 f"cannot convert selected capacity for `{spec.label}`.", 

445 context={"line_id": line.pk, "input_key": spec.key}, 

446 ) from exc 

447 if not converted.is_finite(): 

448 raise FormulaError( 

449 "invalid_driver_input_unit", 

450 f"selected capacity conversion for `{spec.label}` produced a non-finite value.", 

451 context={"line_id": line.pk, "input_key": spec.key}, 

452 ) 

453 return converted 

454 

455 

456def _render_discrete_variant_generated_formula( 

457 *, 

458 mapping: EquipmentMapping, 

459 curve: CostCurve, 

460 variant: CostCurveDiscreteVariant, 

461 formula_specs: Sequence[CostCurveDriverSpec], 

462 render_bindings: dict[str, str], 

463 output_unit_expression: str, 

464) -> str: 

465 variant_formula = build_cost_curve_variant_formula( 

466 curve, 

467 variant, 

468 formula_specs=formula_specs, 

469 ) 

470 expression = variant_formula.render_property_formula(render_bindings) 

471 for factor in _generated_capital_factors(mapping, curve): 471 ↛ 472line 471 didn't jump to line 472 because the loop on line 471 never started

472 expression = f"(({expression}) * {_decimal_literal(factor)})" 

473 return f"(({expression}) * ({output_unit_expression}))" 

474 

475 

476def _generated_capital_factors(mapping, curve) -> tuple[Decimal, ...]: 

477 factors: list[Decimal] = [] 

478 index_factor = capital_index_adjustment(mapping.costable_item.study, curve).factor 

479 if index_factor != Decimal("1"): 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true

480 factors.append(index_factor) 

481 lang_factor = resolve_lang_factor(mapping).effective_value 

482 if curve.cost_basis == CostBasis.PURCHASE and lang_factor not in (None, Decimal("1")): 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true

483 factors.append(lang_factor) 

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

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

486 if contingency_factor != Decimal("1"): 486 ↛ 487line 486 didn't jump to line 487 because the condition on line 486 was never true

487 factors.append(contingency_factor) 

488 return tuple(factors) 

489 

490 

491def _decimal_literal(value: Decimal) -> str: 

492 return format(value, "f") 

493 

494 

495def _render_driver_input_formula(*, line: CapitalCostLine, spec) -> str: 

496 driver_inputs = line.driver_inputs if isinstance(line.driver_inputs, dict) else {} 

497 driver_input = driver_inputs.get(spec.key) 

498 if not isinstance(driver_input, dict): 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true

499 raise FormulaError( 

500 "missing_driver_input", 

501 f"has no solve-visible input for `{spec.label}`.", 

502 context={"line_id": line.pk, "input_key": spec.key}, 

503 ) 

504 source_unit = normalize_economics_unit_notation(driver_input.get("unit") or spec.unit) 

505 target_unit = normalize_economics_unit_notation(spec.unit) 

506 if not _unit_conversion_supported(source_unit, target_unit): 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

507 raise FormulaError( 

508 "incompatible_driver_input_unit", 

509 f"needs `{source_unit}` to `{target_unit}` conversion before it can be optimised.", 

510 context={"line_id": line.pk, "input_key": spec.key}, 

511 ) 

512 source_expression = _driver_input_source_expression(line=line, spec=spec, driver_input=driver_input, source_unit=source_unit) 

513 target_unit_expression = formula_unit_expression(target_unit) 

514 if target_unit_expression is None: 514 ↛ 515line 514 didn't jump to line 515 because the condition on line 514 was never true

515 raise FormulaError( 

516 "unsupported_driver_input_unit", 

517 f"has unsupported input unit `{target_unit}`.", 

518 context={"line_id": line.pk, "input_key": spec.key}, 

519 ) 

520 if target_unit_expression == "dimensionless": 

521 return f"convert({source_expression}, {target_unit_expression})" 

522 return f"(convert({source_expression}, {target_unit_expression}) / ({target_unit_expression}))" 

523 

524 

525def _driver_input_source_expression(*, line: CapitalCostLine, spec, driver_input: dict, source_unit: str) -> str: 

526 if driver_input.get("source") == "property": 

527 property_info_id = driver_input.get("property_info") 

528 property_info = PropertyInfo.objects.filter(pk=property_info_id, flowsheet_id=line.flowsheet_id).first() 

529 if property_info is None: 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true

530 raise FormulaError( 

531 "missing_driver_input_property", 

532 f"has no solve-visible property for `{spec.label}`.", 

533 context={"line_id": line.pk, "input_key": spec.key}, 

534 ) 

535 property_value = property_info.values.order_by("pk").first() 

536 if property_value is None or not property_value.has_value(): 

537 raise FormulaError( 

538 "missing_driver_input_property_value", 

539 f"`{spec.label}` property has no scalar value.", 

540 context={"line_id": line.pk, "input_key": spec.key}, 

541 ) 

542 property_unit = normalize_economics_unit_notation(property_info.unit or source_unit) 

543 if not _unit_conversion_supported(property_unit, spec.unit): 543 ↛ 544line 543 didn't jump to line 544 because the condition on line 543 was never true

544 raise FormulaError( 

545 "incompatible_driver_input_property_unit", 

546 f"needs `{property_unit}` to `{spec.unit}` conversion before it can be optimised.", 

547 context={"line_id": line.pk, "input_key": spec.key}, 

548 ) 

549 return _property_mention(property_info, property_value) 

550 if driver_input.get("source") == "manual": 550 ↛ 566line 550 didn't jump to line 566 because the condition on line 550 was always true

551 try: 

552 manual_value = Decimal(str(driver_input.get("manual_value") or "")) 

553 except (InvalidOperation, ValueError) as exc: 

554 raise FormulaError( 

555 "invalid_driver_input_manual_value", 

556 f"`{spec.label}` manual value is not numeric.", 

557 context={"line_id": line.pk, "input_key": spec.key}, 

558 ) from exc 

559 if not manual_value.is_finite(): 

560 raise FormulaError( 

561 "invalid_driver_input_manual_value", 

562 f"`{spec.label}` manual value is not finite.", 

563 context={"line_id": line.pk, "input_key": spec.key}, 

564 ) 

565 return _manual_value_with_unit(manual_value, source_unit) 

566 raise FormulaError( 

567 "missing_driver_input_source", 

568 f"has no solve-visible source for `{spec.label}`.", 

569 context={"line_id": line.pk, "input_key": spec.key}, 

570 ) 

571 

572 

573def operating_line_property_expression(line: OperatingCostLine, *, study: EconomicsStudy) -> NativePropertyExpression: 

574 try: 

575 operating_formula = build_operating_line_formula(line, study=study) 

576 except FormulaError as exc: 

577 return NativePropertyExpression("", False, f"`{line.label}` {exc.message}") 

578 value = operating_formula.evaluate() 

579 

580 source_property = None 

581 if line.source_property_info_id is None: 

582 try: 

583 formula = render_operating_line_property_formula( 

584 operating_formula, 

585 source_property_formula=source_property, 

586 ) 

587 except FormulaError as exc: 

588 return NativePropertyExpression("", False, f"`{line.label}` {exc.message}") 

589 return NativePropertyExpression(validate_formula_length(formula), True, value=value) 

590 

591 source_value = line.source_property_info.values.order_by("pk").first() 

592 if source_value is None or not source_value.has_value(): 

593 return NativePropertyExpression("", False, f"`{line.label}` source property has no value.") 

594 source_property = _property_mention(line.source_property_info, source_value) 

595 try: 

596 formula = render_operating_line_property_formula( 

597 operating_formula, 

598 source_property_formula=source_property, 

599 ) 

600 except FormulaError as exc: 

601 return NativePropertyExpression("", False, f"`{line.label}` {exc.message}") 

602 return NativePropertyExpression(validate_formula_length(formula), True, value=value) 

603 

604 

605def _sum_terms(terms: list[str], *, unit: str) -> NativePropertyExpression: 

606 if not terms: 

607 return NativePropertyExpression(_unit_literal(Decimal("0"), unit), True) 

608 return NativePropertyExpression(validate_formula_length(" + ".join(_sum_term(term) for term in terms)), True) 

609 

610 

611def _sum_term(term: str) -> str: 

612 """Parenthesize composite additive terms without wrapping simple property references.""" 

613 

614 if PROPERTY_MENTION_PATTERN.fullmatch(term): 

615 return term 

616 if _has_top_level_additive_operator(term): 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true

617 return f"({term})" 

618 return term 

619 

620 

621def _has_top_level_additive_operator(expression: str) -> bool: 

622 depth = 0 

623 for index, char in enumerate(expression): 

624 if char == "(": 

625 depth += 1 

626 elif char == ")": 

627 depth = max(depth - 1, 0) 

628 elif depth == 0 and index > 0 and expression[index - 1 : index + 2] in {" + ", " - "}: 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true

629 return True 

630 return False 

631 

632 

633def _property_mention(property_info: PropertyInfo, property_value: PropertyValue) -> str: 

634 return f"@[{property_info.displayName}](prop{property_value.pk})" 

635 

636 

637def _unit_conversion_supported(source_unit: str, target_unit: str) -> bool: 

638 try: 

639 return can_convert(source_unit, target_unit) 

640 except Exception: 

641 return False 

642 

643 

644def _manual_value_with_unit(value: Decimal, unit: str) -> str: 

645 unit_expression = formula_unit_expression(unit) 

646 if not unit_expression: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true

647 return format(value, "f") 

648 return f"({format(value, 'f')} * ({unit_expression}))" 

649 

650 

651def _unit_literal(value: Decimal, unit: str) -> str: 

652 unit_expression = formula_unit_expression(unit) 

653 if not unit_expression: 653 ↛ 654line 653 didn't jump to line 654 because the condition on line 653 was never true

654 return format(value, "f") 

655 if value == Decimal("0"): 655 ↛ 657line 655 didn't jump to line 657 because the condition on line 655 was always true

656 return "0" 

657 if value == Decimal("1"): 

658 return f"({unit_expression})" 

659 if value == Decimal("-1"): 

660 return f"-({unit_expression})" 

661 return f"({format(value, 'f')} * ({unit_expression}))" 

662 

663 

664def _study_currency(study: EconomicsStudy) -> str: 

665 assumptions = get_settings_profile(study) 

666 if assumptions is None: 

667 return "NZD" 

668 return assumptions.currency or "NZD"