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

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

44 

45 

46@dataclass(frozen=True) 

47class AnnualBasisQuantity: 

48 quantity: Decimal 

49 unit: str 

50 

51 

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

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

54 

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 

61 

62 try: 

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

64 except FormulaError: 

65 return None 

66 

67 

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 

72 

73 try: 

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

75 except FormulaError: 

76 return None 

77 

78 

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) 

83 

84 

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 ) 

101 

102 

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 } 

132 

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 } 

146 

147 

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" 

165 

166 

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) 

178 

179 

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 

184 

185 try: 

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

187 except FormulaError: 

188 return None 

189 

190 

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 

197 

198 

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 ) 

213 

214 

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 

238 

239 

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 

249 

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

251 if direct is not None: 

252 return direct 

253 

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 

263 

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 

275 

276 return None 

277 

278 

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

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

281 

282 

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 

288 

289 

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 

309 

310 

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 

325 

326 

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 

340 

341 

342def _override_text(value) -> str: 

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

344 

345 

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