Coverage for backend/django/Economics/costing/operating/line_calculation.py: 74%
158 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
1"""Operating-line amount calculations shared by metrics and result rows.
3Operating lines are configured as rate-times-quantity calculations, optionally
4backed by a current flowsheet property such as ``kg/s`` mass flow or ``kW``
5power. Quantities are annualized into the denominator unit of their price before
6the price is applied.
7"""
9from __future__ import annotations
11from dataclasses import dataclass
12from decimal import Decimal, InvalidOperation
14from django.db.models import Q
16from idaes_factory.unit_conversion.unit_conversion import pint_registry
17from Economics.shared.choices import DefaultRateReviewStatus, DefaultRateType, DefaultRateValueKind, OperatingLineCategory
18from Economics.reference_data.models import EconomicsDefaultRate
19from Economics.studies.models import EconomicsStudy
20from Economics.costing.models import OperatingCostLine
21from Economics.settings_profiles.services.settings_profiles import get_settings_profile
22from Economics.shared.unit_conversion import convert_quantity
23from Economics.shared.unit_options import MAINTENANCE_RATE_UNIT
26_TIME_DENOMINATORS = {
27 "s",
28 "sec",
29 "second",
30 "seconds",
31 "min",
32 "minute",
33 "minutes",
34 "h",
35 "hr",
36 "hour",
37 "hours",
38 "y",
39 "yr",
40 "year",
41 "years",
42}
43_OPERATING_LINE_RATE_QUANTUM = Decimal("0.00000001")
46@dataclass(frozen=True)
47class AnnualBasisQuantity:
48 quantity: Decimal
49 unit: str
52def operating_line_annual_amount(line: OperatingCostLine, *, study: EconomicsStudy) -> Decimal | None:
53 """Return the annual signed-neutral amount for one operating line.
55 The return value is always a positive annual amount. Category polarity, such
56 as sold outputs reducing net opex, is applied by callers so metrics and
57 presentation rows can keep using the same amount resolver.
58 """
59 from Economics.formulas.builders.operating import build_operating_line_formula
60 from Economics.formulas.engine.core import FormulaError
62 try:
63 return build_operating_line_formula(line, study=study).evaluate()
64 except FormulaError:
65 return None
68def operating_line_annual_basis_quantity(line: OperatingCostLine, *, study: EconomicsStudy) -> AnnualBasisQuantity | None:
69 """Return the annualized physical quantity behind a quantity/rate line."""
70 from Economics.formulas.builders.operating import build_operating_line_formula
71 from Economics.formulas.engine.core import FormulaError
73 try:
74 return build_operating_line_formula(line, study=study).annual_basis
75 except FormulaError:
76 return None
79def reviewed_default_rate_for_operating_category(category: str) -> EconomicsDefaultRate | None:
80 """Return the reviewed numeric default that should seed a new line."""
81 desired_rate_type = _default_rate_type_for_category(category)
82 return reviewed_default_rate_for_type(desired_rate_type)
85def reviewed_default_rate_for_type(rate_type: str | None) -> EconomicsDefaultRate | None:
86 """Return the reviewed default for one project setup rate type."""
87 if rate_type is None: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 return None
89 return (
90 EconomicsDefaultRate.objects.filter(
91 rate_type=rate_type,
92 review_status=DefaultRateReviewStatus.REVIEWED,
93 )
94 .filter(
95 Q(value_kind=DefaultRateValueKind.REVIEWED_DEFAULT, value__isnull=False)
96 | Q(value_kind=DefaultRateValueKind.DERIVED_TEMPLATE)
97 )
98 .order_by("pk")
99 .first()
100 )
103def operating_line_rate_defaults_for_category(
104 *,
105 category: str,
106 study: EconomicsStudy | None = None,
107 currency: str = "NZD",
108 property_unit: str = "",
109 rate_type: str | None = None,
110) -> dict[str, Decimal | str | EconomicsDefaultRate | None]:
111 """Return the study-selected operating default for a generated line."""
112 desired_rate_type = rate_type if rate_type is not None else _default_rate_type_for_category(category)
113 override = _default_rate_override_for_category(study=study, category=category, rate_type=desired_rate_type)
114 if override and override.get("mode") == "custom":
115 return {
116 "source_default_rate": None,
117 "rate_amount": _override_decimal(
118 override.get("value"),
119 category=category,
120 unit=_override_text(override.get("unit")),
121 ),
122 "rate_unit": MAINTENANCE_RATE_UNIT
123 if desired_rate_type == DefaultRateType.MAINTENANCE
124 else _override_text(override.get("unit"))
125 or default_rate_unit_for_property(
126 category=category,
127 currency=currency,
128 property_unit=property_unit,
129 rate_type=desired_rate_type,
130 ),
131 }
133 default_rate = _source_default_rate_from_override(override, desired_rate_type) if override else None
134 default_rate = default_rate or reviewed_default_rate_for_type(desired_rate_type)
135 return {
136 "source_default_rate": default_rate,
137 "rate_amount": operating_line_rate_amount_from_default(default_rate, override=override),
138 "rate_unit": default_rate_unit_for_property(
139 category=category,
140 currency=currency,
141 property_unit=property_unit,
142 default_rate=default_rate,
143 rate_type=desired_rate_type,
144 ),
145 }
148def default_rate_unit_for_property(
149 *,
150 category: str,
151 currency: str,
152 property_unit: str,
153 default_rate: EconomicsDefaultRate | None = None,
154 rate_type: str | None = None,
155) -> str:
156 """Return the pricing unit to show for a property-backed operating line."""
157 desired_rate_type = rate_type if rate_type is not None else _default_rate_type_for_category(category)
158 if desired_rate_type == DefaultRateType.MAINTENANCE:
159 return MAINTENANCE_RATE_UNIT
160 default_rate = default_rate or reviewed_default_rate_for_type(desired_rate_type)
161 if default_rate is not None and default_rate.display_unit:
162 return default_rate.display_unit
163 annual_basis_unit = strip_single_time_denominator(property_unit)
164 return f"{currency}/{annual_basis_unit}" if annual_basis_unit else f"{currency}/unit"
167def operating_line_rate_amount_from_default(
168 default_rate: EconomicsDefaultRate | None,
169 *,
170 override: dict | None = None,
171) -> Decimal | None:
172 """Return a default rate rounded to the persisted operating-line precision."""
173 if default_rate is None:
174 return None
175 if default_rate.value is None:
176 return _derived_steam_rate_amount(default_rate, override=override)
177 return default_rate.value.quantize(_OPERATING_LINE_RATE_QUANTUM)
180def _derived_steam_rate_amount(default_rate: EconomicsDefaultRate, *, override: dict | None) -> Decimal | None:
181 """Calculate a steam template rate without storing an opaque steam price."""
182 from Economics.formulas.engine.core import FormulaError
183 from Economics.formulas.builders.operating import build_derived_steam_rate_formula
185 try:
186 return build_derived_steam_rate_formula(default_rate, override=override).evaluate()
187 except FormulaError:
188 return None
191def strip_single_time_denominator(unit: str) -> str:
192 """Return ``kg`` for simple rate units such as ``kg/s`` or ``kg/year``."""
193 if not unit or "/" not in unit:
194 return unit
195 numerator, denominator = [part.strip() for part in unit.rsplit("/", 1)]
196 return numerator if denominator in _TIME_DENOMINATORS else unit
199def annualized_basis_quantity(
200 value: Decimal,
201 *,
202 source_unit: str,
203 target_unit: str,
204 study: EconomicsStudy,
205) -> Decimal | None:
206 """Annualize a physical quantity into the requested target unit."""
207 return _annualized_basis_quantity(
208 value,
209 source_unit=source_unit,
210 target_unit=target_unit,
211 study=study,
212 )
215def annualization_requires_operating_hours(*, source_unit: str, target_unit: str) -> bool:
216 """Return whether this unit pair needs operating hours to annualize."""
217 if not source_unit or not target_unit: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 return False
219 direct = convert_quantity(value=Decimal("1"), source_unit=source_unit, target_unit=target_unit)
220 if direct is not None: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 return False
222 if _is_annual_rate_unit(source_unit): 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 yearly = convert_quantity(
224 value=Decimal("1"),
225 source_unit=source_unit,
226 target_unit=target_unit,
227 multiplier=pint_registry.Quantity(1, "year"),
228 )
229 if yearly is not None:
230 return False
231 hourly = convert_quantity(
232 value=Decimal("1"),
233 source_unit=source_unit,
234 target_unit=target_unit,
235 multiplier=pint_registry.Quantity(1, "hour"),
236 )
237 return hourly is not None
240def _annualized_basis_quantity(
241 value: Decimal,
242 *,
243 source_unit: str,
244 target_unit: str,
245 study: EconomicsStudy,
246) -> Decimal | None:
247 if not source_unit or not target_unit: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 return value
250 direct = convert_quantity(value=value, source_unit=source_unit, target_unit=target_unit)
251 if direct is not None:
252 return direct
254 if _is_annual_rate_unit(source_unit):
255 yearly = convert_quantity(
256 value=value,
257 source_unit=source_unit,
258 target_unit=target_unit,
259 multiplier=pint_registry.Quantity(1, "year"),
260 )
261 if yearly is not None: 261 ↛ 264line 261 didn't jump to line 264 because the condition on line 261 was always true
262 return yearly
264 annual_operating_hours = _study_annual_operating_hours(study)
265 if annual_operating_hours is not None:
266 operating_duration = pint_registry.Quantity(annual_operating_hours, "hour")
267 annualized = convert_quantity(
268 value=value,
269 source_unit=source_unit,
270 target_unit=target_unit,
271 multiplier=operating_duration,
272 )
273 if annualized is not None:
274 return annualized
276 return None
279def _is_annual_rate_unit(unit: str) -> bool:
280 return "/" in unit and unit.rsplit("/", 1)[1].strip() in {"y", "yr", "year", "years"}
283def _study_annual_operating_hours(study: EconomicsStudy) -> Decimal | None:
284 settings_profile = get_settings_profile(study)
285 if settings_profile is None: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true
286 return None
287 return settings_profile.annual_operating_hours
290def _default_rate_override_for_category(
291 *,
292 study: EconomicsStudy | None,
293 category: str,
294 rate_type: str | None = None,
295) -> dict | None:
296 if study is None: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true
297 return None
298 rate_type = rate_type if rate_type is not None else _default_rate_type_for_category(category)
299 if rate_type is None: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true
300 return None
301 settings_profile = get_settings_profile(study)
302 if settings_profile is None: 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true
303 return None
304 overrides = settings_profile.default_rate_overrides
305 if not isinstance(overrides, dict): 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true
306 return None
307 override = overrides.get(rate_type)
308 return override if isinstance(override, dict) else None
311def _source_default_rate_from_override(
312 override: dict | None,
313 rate_type: str | None,
314) -> EconomicsDefaultRate | None:
315 if not override or override.get("mode") != "source": 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true
316 return None
317 source_default_rate = override.get("source_default_rate")
318 if source_default_rate in (None, ""): 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 return None
320 try:
321 default_rate = EconomicsDefaultRate.objects.get(pk=source_default_rate)
322 except (EconomicsDefaultRate.DoesNotExist, TypeError, ValueError):
323 return None
324 return default_rate if rate_type is not None and default_rate.rate_type == rate_type else None
327def _override_decimal(value, *, category: str, unit: str) -> Decimal | None:
328 if value in (None, ""): 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true
329 return None
330 try:
331 amount = Decimal(str(value))
332 except (InvalidOperation, ValueError, TypeError):
333 return None
334 if category == OperatingLineCategory.MAINTENANCE and unit.startswith("%"):
335 amount = amount / Decimal("100")
336 try:
337 return amount.quantize(_OPERATING_LINE_RATE_QUANTUM)
338 except InvalidOperation:
339 return None
342def _override_text(value) -> str:
343 return value.strip() if isinstance(value, str) else ""
346def _default_rate_type_for_category(category: str) -> str | None:
347 if category == OperatingLineCategory.ENERGY:
348 return DefaultRateType.ELECTRICITY
349 if category == OperatingLineCategory.MAINTENANCE: 349 ↛ 351line 349 didn't jump to line 351 because the condition on line 349 was always true
350 return DefaultRateType.MAINTENANCE
351 return None