Coverage for backend/django/Economics/costing/operating/line_calculation.py: 74%
158 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
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 "minute",
32 "h",
33 "hr",
34 "hour",
35 "hours",
36 "y",
37 "yr",
38 "year",
39 "years",
40}
41_OPERATING_LINE_RATE_QUANTUM = Decimal("0.00000001")
44@dataclass(frozen=True)
45class AnnualBasisQuantity:
46 quantity: Decimal
47 unit: str
50def operating_line_annual_amount(line: OperatingCostLine, *, study: EconomicsStudy) -> Decimal | None:
51 """Return the annual signed-neutral amount for one operating line.
53 The return value is always a positive annual amount. Category polarity, such
54 as sold outputs reducing net opex, is applied by callers so metrics and
55 presentation rows can keep using the same amount resolver.
56 """
57 from Economics.formulas.builders.operating import build_operating_line_formula
58 from Economics.formulas.engine.core import FormulaError
60 try:
61 return build_operating_line_formula(line, study=study).evaluate()
62 except FormulaError:
63 return None
66def operating_line_annual_basis_quantity(line: OperatingCostLine, *, study: EconomicsStudy) -> AnnualBasisQuantity | None:
67 """Return the annualized physical quantity behind a quantity/rate line."""
68 from Economics.formulas.builders.operating import build_operating_line_formula
69 from Economics.formulas.engine.core import FormulaError
71 try:
72 return build_operating_line_formula(line, study=study).annual_basis
73 except FormulaError:
74 return None
77def reviewed_default_rate_for_operating_category(category: str) -> EconomicsDefaultRate | None:
78 """Return the reviewed numeric default that should seed a new line."""
79 desired_rate_type = _default_rate_type_for_category(category)
80 return reviewed_default_rate_for_type(desired_rate_type)
83def reviewed_default_rate_for_type(rate_type: str | None) -> EconomicsDefaultRate | None:
84 """Return the reviewed default for one project setup rate type."""
85 if rate_type is None: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 return None
87 return (
88 EconomicsDefaultRate.objects.filter(
89 rate_type=rate_type,
90 review_status=DefaultRateReviewStatus.REVIEWED,
91 )
92 .filter(
93 Q(value_kind=DefaultRateValueKind.REVIEWED_DEFAULT, value__isnull=False)
94 | Q(value_kind=DefaultRateValueKind.DERIVED_TEMPLATE)
95 )
96 .order_by("pk")
97 .first()
98 )
101def operating_line_rate_defaults_for_category(
102 *,
103 category: str,
104 study: EconomicsStudy | None = None,
105 currency: str = "NZD",
106 property_unit: str = "",
107 rate_type: str | None = None,
108) -> dict[str, Decimal | str | EconomicsDefaultRate | None]:
109 """Return the study-selected operating default for a generated line."""
110 desired_rate_type = rate_type if rate_type is not None else _default_rate_type_for_category(category)
111 override = _default_rate_override_for_category(study=study, category=category, rate_type=desired_rate_type)
112 if override and override.get("mode") == "custom":
113 return {
114 "source_default_rate": None,
115 "rate_amount": _override_decimal(
116 override.get("value"),
117 category=category,
118 unit=_override_text(override.get("unit")),
119 ),
120 "rate_unit": MAINTENANCE_RATE_UNIT
121 if desired_rate_type == DefaultRateType.MAINTENANCE
122 else _override_text(override.get("unit"))
123 or default_rate_unit_for_property(
124 category=category,
125 currency=currency,
126 property_unit=property_unit,
127 rate_type=desired_rate_type,
128 ),
129 }
131 default_rate = _source_default_rate_from_override(override, desired_rate_type) if override else None
132 default_rate = default_rate or reviewed_default_rate_for_type(desired_rate_type)
133 return {
134 "source_default_rate": default_rate,
135 "rate_amount": operating_line_rate_amount_from_default(default_rate, override=override),
136 "rate_unit": default_rate_unit_for_property(
137 category=category,
138 currency=currency,
139 property_unit=property_unit,
140 default_rate=default_rate,
141 rate_type=desired_rate_type,
142 ),
143 }
146def default_rate_unit_for_property(
147 *,
148 category: str,
149 currency: str,
150 property_unit: str,
151 default_rate: EconomicsDefaultRate | None = None,
152 rate_type: str | None = None,
153) -> str:
154 """Return the pricing unit to show for a property-backed operating line."""
155 desired_rate_type = rate_type if rate_type is not None else _default_rate_type_for_category(category)
156 if desired_rate_type == DefaultRateType.MAINTENANCE:
157 return MAINTENANCE_RATE_UNIT
158 default_rate = default_rate or reviewed_default_rate_for_type(desired_rate_type)
159 if default_rate is not None and default_rate.display_unit:
160 return default_rate.display_unit
161 annual_basis_unit = strip_single_time_denominator(property_unit)
162 return f"{currency}/{annual_basis_unit}" if annual_basis_unit else f"{currency}/unit"
165def operating_line_rate_amount_from_default(
166 default_rate: EconomicsDefaultRate | None,
167 *,
168 override: dict | None = None,
169) -> Decimal | None:
170 """Return a default rate rounded to the persisted operating-line precision."""
171 if default_rate is None:
172 return None
173 if default_rate.value is None:
174 return _derived_steam_rate_amount(default_rate, override=override)
175 return default_rate.value.quantize(_OPERATING_LINE_RATE_QUANTUM)
178def _derived_steam_rate_amount(default_rate: EconomicsDefaultRate, *, override: dict | None) -> Decimal | None:
179 """Calculate a steam template rate without storing an opaque steam price."""
180 from Economics.formulas.engine.core import FormulaError
181 from Economics.formulas.builders.operating import build_derived_steam_rate_formula
183 try:
184 return build_derived_steam_rate_formula(default_rate, override=override).evaluate()
185 except FormulaError:
186 return None
189def strip_single_time_denominator(unit: str) -> str:
190 """Return ``kg`` for simple rate units such as ``kg/s`` or ``kg/year``."""
191 if not unit or "/" not in unit:
192 return unit
193 numerator, denominator = [part.strip() for part in unit.rsplit("/", 1)]
194 return numerator if denominator in _TIME_DENOMINATORS else unit
197def annualized_basis_quantity(
198 value: Decimal,
199 *,
200 source_unit: str,
201 target_unit: str,
202 study: EconomicsStudy,
203) -> Decimal | None:
204 """Annualize a physical quantity into the requested target unit."""
205 return _annualized_basis_quantity(
206 value,
207 source_unit=source_unit,
208 target_unit=target_unit,
209 study=study,
210 )
213def annualization_requires_operating_hours(*, source_unit: str, target_unit: str) -> bool:
214 """Return whether this unit pair needs operating hours to annualize."""
215 if not source_unit or not target_unit: 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true
216 return False
217 direct = convert_quantity(value=Decimal("1"), source_unit=source_unit, target_unit=target_unit)
218 if direct is not None: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 return False
220 if _is_annual_rate_unit(source_unit): 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 yearly = convert_quantity(
222 value=Decimal("1"),
223 source_unit=source_unit,
224 target_unit=target_unit,
225 multiplier=pint_registry.Quantity(1, "year"),
226 )
227 if yearly is not None:
228 return False
229 hourly = convert_quantity(
230 value=Decimal("1"),
231 source_unit=source_unit,
232 target_unit=target_unit,
233 multiplier=pint_registry.Quantity(1, "hour"),
234 )
235 return hourly is not None
238def _annualized_basis_quantity(
239 value: Decimal,
240 *,
241 source_unit: str,
242 target_unit: str,
243 study: EconomicsStudy,
244) -> Decimal | None:
245 if not source_unit or not target_unit: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 return value
248 direct = convert_quantity(value=value, source_unit=source_unit, target_unit=target_unit)
249 if direct is not None:
250 return direct
252 if _is_annual_rate_unit(source_unit):
253 yearly = convert_quantity(
254 value=value,
255 source_unit=source_unit,
256 target_unit=target_unit,
257 multiplier=pint_registry.Quantity(1, "year"),
258 )
259 if yearly is not None: 259 ↛ 262line 259 didn't jump to line 262 because the condition on line 259 was always true
260 return yearly
262 annual_operating_hours = _study_annual_operating_hours(study)
263 if annual_operating_hours is not None:
264 operating_duration = pint_registry.Quantity(annual_operating_hours, "hour")
265 annualized = convert_quantity(
266 value=value,
267 source_unit=source_unit,
268 target_unit=target_unit,
269 multiplier=operating_duration,
270 )
271 if annualized is not None:
272 return annualized
274 return None
277def _is_annual_rate_unit(unit: str) -> bool:
278 return "/" in unit and unit.rsplit("/", 1)[1].strip() in {"y", "yr", "year", "years"}
281def _study_annual_operating_hours(study: EconomicsStudy) -> Decimal | None:
282 settings_profile = get_settings_profile(study)
283 if settings_profile is None: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true
284 return None
285 return settings_profile.annual_operating_hours
288def _default_rate_override_for_category(
289 *,
290 study: EconomicsStudy | None,
291 category: str,
292 rate_type: str | None = None,
293) -> dict | None:
294 if study is None: 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true
295 return None
296 rate_type = rate_type if rate_type is not None else _default_rate_type_for_category(category)
297 if rate_type is None: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true
298 return None
299 settings_profile = get_settings_profile(study)
300 if settings_profile is None: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true
301 return None
302 overrides = settings_profile.default_rate_overrides
303 if not isinstance(overrides, dict): 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 return None
305 override = overrides.get(rate_type)
306 return override if isinstance(override, dict) else None
309def _source_default_rate_from_override(
310 override: dict | None,
311 rate_type: str | None,
312) -> EconomicsDefaultRate | None:
313 if not override or override.get("mode") != "source": 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 return None
315 source_default_rate = override.get("source_default_rate")
316 if source_default_rate in (None, ""): 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true
317 return None
318 try:
319 default_rate = EconomicsDefaultRate.objects.get(pk=source_default_rate)
320 except (EconomicsDefaultRate.DoesNotExist, TypeError, ValueError):
321 return None
322 return default_rate if rate_type is not None and default_rate.rate_type == rate_type else None
325def _override_decimal(value, *, category: str, unit: str) -> Decimal | None:
326 if value in (None, ""): 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 return None
328 try:
329 amount = Decimal(str(value))
330 except (InvalidOperation, ValueError, TypeError):
331 return None
332 if category == OperatingLineCategory.MAINTENANCE and unit.startswith("%"):
333 amount = amount / Decimal("100")
334 try:
335 return amount.quantize(_OPERATING_LINE_RATE_QUANTUM)
336 except InvalidOperation:
337 return None
340def _override_text(value) -> str:
341 return value.strip() if isinstance(value, str) else ""
344def _default_rate_type_for_category(category: str) -> str | None:
345 if category == OperatingLineCategory.ENERGY:
346 return DefaultRateType.ELECTRICITY
347 if category == OperatingLineCategory.MAINTENANCE: 347 ↛ 349line 347 didn't jump to line 349 because the condition on line 347 was always true
348 return DefaultRateType.MAINTENANCE
349 return None