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
« prev ^ index » next coverage.py v7.10.7, created at 2026-06-23 21:51 +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_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 )
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(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 )
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()
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)
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)
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)
611def _sum_term(term: str) -> str:
612 """Parenthesize composite additive terms without wrapping simple property references."""
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
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
633def _property_mention(property_info: PropertyInfo, property_value: PropertyValue) -> str:
634 return f"@[{property_info.displayName}](prop{property_value.pk})"
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
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}))"
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}))"
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"