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-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, DecimalException, InvalidOperation
5import re
6from typing import Sequence, TypeAlias, TypedDict
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
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
53PROPERTY_MENTION_PATTERN = re.compile(r"^@\[[^\]]+\]\(prop\d+\)$")
56SelectorRawInputValue: TypeAlias = Decimal | int | float | str | None
59class SelectorRawValue(TypedDict):
60 value: SelectorRawInputValue
61 unit: str
64@dataclass(frozen=True)
65class NativePropertyExpression:
66 """Formula plus optimisation visibility diagnostics for a native property."""
68 formula: str
69 solve_visible: bool
70 blocked_reason: str = ""
71 value: Decimal | None = None
72 formula_record_id: int | None = None
75def base_capital_cost_property_expression(study: EconomicsStudy) -> NativePropertyExpression:
76 """Render the solve-visible generated-unit CAPEX subtotal property formula."""
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 )
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 )
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."""
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")
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 )
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}")
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 )
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
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
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 ""
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 }
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"])
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
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_state=line.flowsheet_state,
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 )
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
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
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}))"
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)
491def _decimal_literal(value: Decimal) -> str:
492 return format(value, "f")
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}))"
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(
529 pk=property_info_id,
530 flowsheet_state=line.flowsheet_state,
531 ).first()
532 if property_info is None: 532 ↛ 533line 532 didn't jump to line 533 because the condition on line 532 was never true
533 raise FormulaError(
534 "missing_driver_input_property",
535 f"has no solve-visible property for `{spec.label}`.",
536 context={"line_id": line.pk, "input_key": spec.key},
537 )
538 property_value = property_info.values.order_by("pk").first()
539 if property_value is None or not property_value.has_value():
540 raise FormulaError(
541 "missing_driver_input_property_value",
542 f"`{spec.label}` property has no scalar value.",
543 context={"line_id": line.pk, "input_key": spec.key},
544 )
545 property_unit = normalize_economics_unit_notation(property_info.unit or source_unit)
546 if not _unit_conversion_supported(property_unit, spec.unit): 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true
547 raise FormulaError(
548 "incompatible_driver_input_property_unit",
549 f"needs `{property_unit}` to `{spec.unit}` conversion before it can be optimised.",
550 context={"line_id": line.pk, "input_key": spec.key},
551 )
552 return _property_mention(property_info, property_value)
553 if driver_input.get("source") == "manual": 553 ↛ 569line 553 didn't jump to line 569 because the condition on line 553 was always true
554 try:
555 manual_value = Decimal(str(driver_input.get("manual_value") or ""))
556 except (InvalidOperation, ValueError) as exc:
557 raise FormulaError(
558 "invalid_driver_input_manual_value",
559 f"`{spec.label}` manual value is not numeric.",
560 context={"line_id": line.pk, "input_key": spec.key},
561 ) from exc
562 if not manual_value.is_finite():
563 raise FormulaError(
564 "invalid_driver_input_manual_value",
565 f"`{spec.label}` manual value is not finite.",
566 context={"line_id": line.pk, "input_key": spec.key},
567 )
568 return _manual_value_with_unit(manual_value, source_unit)
569 raise FormulaError(
570 "missing_driver_input_source",
571 f"has no solve-visible source for `{spec.label}`.",
572 context={"line_id": line.pk, "input_key": spec.key},
573 )
576def operating_line_property_expression(line: OperatingCostLine, *, study: EconomicsStudy) -> NativePropertyExpression:
577 try:
578 operating_formula = build_operating_line_formula(line, study=study)
579 except FormulaError as exc:
580 return NativePropertyExpression("", False, f"`{line.label}` {exc.message}")
581 value = operating_formula.evaluate()
583 source_property = None
584 if line.source_property_info_id is None:
585 try:
586 formula = render_operating_line_property_formula(
587 operating_formula,
588 source_property_formula=source_property,
589 )
590 except FormulaError as exc:
591 return NativePropertyExpression("", False, f"`{line.label}` {exc.message}")
592 return NativePropertyExpression(validate_formula_length(formula), True, value=value)
594 source_value = line.source_property_info.values.order_by("pk").first()
595 if source_value is None or not source_value.has_value():
596 return NativePropertyExpression("", False, f"`{line.label}` source property has no value.")
597 source_property = _property_mention(line.source_property_info, source_value)
598 try:
599 formula = render_operating_line_property_formula(
600 operating_formula,
601 source_property_formula=source_property,
602 )
603 except FormulaError as exc:
604 return NativePropertyExpression("", False, f"`{line.label}` {exc.message}")
605 return NativePropertyExpression(validate_formula_length(formula), True, value=value)
608def _sum_terms(terms: list[str], *, unit: str) -> NativePropertyExpression:
609 if not terms:
610 return NativePropertyExpression(_unit_literal(Decimal("0"), unit), True)
611 return NativePropertyExpression(validate_formula_length(" + ".join(_sum_term(term) for term in terms)), True)
614def _sum_term(term: str) -> str:
615 """Parenthesize composite additive terms without wrapping simple property references."""
617 if PROPERTY_MENTION_PATTERN.fullmatch(term):
618 return term
619 if _has_top_level_additive_operator(term): 619 ↛ 620line 619 didn't jump to line 620 because the condition on line 619 was never true
620 return f"({term})"
621 return term
624def _has_top_level_additive_operator(expression: str) -> bool:
625 depth = 0
626 for index, char in enumerate(expression):
627 if char == "(":
628 depth += 1
629 elif char == ")":
630 depth = max(depth - 1, 0)
631 elif depth == 0 and index > 0 and expression[index - 1 : index + 2] in {" + ", " - "}: 631 ↛ 632line 631 didn't jump to line 632 because the condition on line 631 was never true
632 return True
633 return False
636def _property_mention(property_info: PropertyInfo, property_value: PropertyValue) -> str:
637 return f"@[{property_info.displayName}](prop{property_value.pk})"
640def _unit_conversion_supported(source_unit: str, target_unit: str) -> bool:
641 try:
642 return can_convert(source_unit, target_unit)
643 except Exception:
644 return False
647def _manual_value_with_unit(value: Decimal, unit: str) -> str:
648 unit_expression = formula_unit_expression(unit)
649 if not unit_expression: 649 ↛ 650line 649 didn't jump to line 650 because the condition on line 649 was never true
650 return format(value, "f")
651 return f"({format(value, 'f')} * ({unit_expression}))"
654def _unit_literal(value: Decimal, unit: str) -> str:
655 unit_expression = formula_unit_expression(unit)
656 if not unit_expression: 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true
657 return format(value, "f")
658 if value == Decimal("0"): 658 ↛ 660line 658 didn't jump to line 660 because the condition on line 658 was always true
659 return "0"
660 if value == Decimal("1"):
661 return f"({unit_expression})"
662 if value == Decimal("-1"):
663 return f"-({unit_expression})"
664 return f"({format(value, 'f')} * ({unit_expression}))"
667def _study_currency(study: EconomicsStudy) -> str:
668 assumptions = get_settings_profile(study)
669 if assumptions is None:
670 return "NZD"
671 return assumptions.currency or "NZD"