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

1"""Operating-line amount calculations shared by metrics and result rows. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11from dataclasses import dataclass 

12from decimal import Decimal, InvalidOperation 

13 

14from django.db.models import Q 

15 

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 

24 

25 

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") 

42 

43 

44@dataclass(frozen=True) 

45class AnnualBasisQuantity: 

46 quantity: Decimal 

47 unit: str 

48 

49 

50def operating_line_annual_amount(line: OperatingCostLine, *, study: EconomicsStudy) -> Decimal | None: 

51 """Return the annual signed-neutral amount for one operating line. 

52 

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 

59 

60 try: 

61 return build_operating_line_formula(line, study=study).evaluate() 

62 except FormulaError: 

63 return None 

64 

65 

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 

70 

71 try: 

72 return build_operating_line_formula(line, study=study).annual_basis 

73 except FormulaError: 

74 return None 

75 

76 

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) 

81 

82 

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 ) 

99 

100 

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 } 

130 

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 } 

144 

145 

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" 

163 

164 

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) 

176 

177 

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 

182 

183 try: 

184 return build_derived_steam_rate_formula(default_rate, override=override).evaluate() 

185 except FormulaError: 

186 return None 

187 

188 

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 

195 

196 

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 ) 

211 

212 

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 

236 

237 

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 

247 

248 direct = convert_quantity(value=value, source_unit=source_unit, target_unit=target_unit) 

249 if direct is not None: 

250 return direct 

251 

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 

261 

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 

273 

274 return None 

275 

276 

277def _is_annual_rate_unit(unit: str) -> bool: 

278 return "/" in unit and unit.rsplit("/", 1)[1].strip() in {"y", "yr", "year", "years"} 

279 

280 

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 

286 

287 

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 

307 

308 

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 

323 

324 

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 

338 

339 

340def _override_text(value) -> str: 

341 return value.strip() if isinstance(value, str) else "" 

342 

343 

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