Coverage for backend/django/Economics/settings_profiles/services/settings_profiles.py: 82%

107 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1from __future__ import annotations 

2 

3from datetime import date 

4from decimal import Decimal 

5from typing import Any, Iterable, TypedDict 

6 

7from django.core.exceptions import ObjectDoesNotExist 

8from django.db import IntegrityError, transaction 

9 

10from core.auxiliary.models import Project 

11from Economics.reference_data.models import CostIndexSeries 

12from Economics.settings_profiles.defaults import apply_new_profile_defaults 

13from Economics.settings_profiles.models import EconomicsAssumptions, EconomicsBaseline, EconomicsSettingsProfile 

14from Economics.studies.models import EconomicsStudy 

15 

16 

17class SettingsProfileValues(TypedDict, total=False): 

18 currency: str 

19 location: str 

20 basis_date: date | None 

21 discount_rate_percent: Decimal | None 

22 project_lifetime_years: int | None 

23 inflation_method: str 

24 annual_operating_hours: Decimal | None 

25 tax_rate_percent: Decimal | None 

26 depreciation_enabled: bool 

27 default_depreciation_life_years: int | None 

28 default_depreciation_salvage_percent: Decimal | None 

29 contingency_percent: Decimal | None 

30 electrical_upgrade_rate_amount: Decimal | None 

31 default_lang_factor: Decimal 

32 capital_index_series: CostIndexSeries | None 

33 operating_index_series: CostIndexSeries | None 

34 default_rate_overrides: dict[str, Any] 

35 manual_capex: Decimal | None 

36 manual_annual_opex: Decimal | None 

37 annual_heat_basis_mode: str 

38 manual_annual_heat_basis: Decimal | None 

39 manual_annual_heat_basis_unit: str 

40 average_power_input: Decimal | None 

41 average_power_unit: str 

42 residual_value: Decimal | None 

43 notes: str 

44 baseline_notes: str 

45 

46 

47def get_settings_profile(study: EconomicsStudy) -> EconomicsSettingsProfile | None: 

48 """Return the active profile selected by a study, if configured.""" 

49 

50 try: 

51 return study.settings_profile 

52 except ObjectDoesNotExist: 

53 return None 

54 

55 

56def default_settings_profile_for_project(project: Project) -> EconomicsSettingsProfile | None: 

57 return ( 

58 EconomicsSettingsProfile._base_manager.filter(project=project, is_default=True) 

59 .order_by("created_at", "pk") 

60 .first() 

61 ) 

62 

63 

64def get_or_create_default_settings_profile_for_project(project: Project) -> EconomicsSettingsProfile: 

65 profile = default_settings_profile_for_project(project) 

66 if profile is not None: 

67 return profile 

68 values = { 

69 "project": project, 

70 "name": "Default study profile", 

71 "is_default": True, 

72 } 

73 apply_new_profile_defaults(values) 

74 try: 

75 return EconomicsSettingsProfile._base_manager.create(**values) 

76 except IntegrityError: 

77 profile = default_settings_profile_for_project(project) 

78 if profile is None: 

79 raise 

80 return profile 

81 

82 

83@transaction.atomic 

84def create_settings_profile_copy( 

85 *, 

86 source: EconomicsSettingsProfile, 

87 name: str, 

88 is_default: bool = False, 

89) -> EconomicsSettingsProfile: 

90 """Copy a settings profile inside the same project with a new name.""" 

91 

92 if is_default: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 EconomicsSettingsProfile._base_manager.filter(project=source.project, is_default=True).update(is_default=False) 

94 values = _profile_values(source) 

95 return EconomicsSettingsProfile._base_manager.create( 

96 project=source.project, 

97 name=name, 

98 is_default=is_default, 

99 **values, 

100 ) 

101 

102 

103@transaction.atomic 

104def update_settings_profile_from_assumptions( 

105 assumptions: EconomicsAssumptions, 

106 *, 

107 field_names: Iterable[str] | None = None, 

108) -> EconomicsSettingsProfile: 

109 """Apply legacy assumptions endpoint values to the study's selected profile.""" 

110 

111 return _update_settings_profile( 

112 _profile_for_legacy_endpoint_update(assumptions.study), 

113 _filtered_legacy_values(_assumption_values(assumptions), field_names), 

114 ) 

115 

116 

117@transaction.atomic 

118def update_settings_profile_from_baseline( 

119 baseline: EconomicsBaseline, 

120 *, 

121 field_names: Iterable[str] | None = None, 

122) -> EconomicsSettingsProfile: 

123 """Apply legacy baseline endpoint values to the study's selected profile.""" 

124 

125 return _update_settings_profile( 

126 _profile_for_legacy_endpoint_update(baseline.study), 

127 _filtered_legacy_values( 

128 _baseline_values(baseline), 

129 field_names, 

130 field_map={"notes": "baseline_notes"}, 

131 ), 

132 ) 

133 

134 

135def assumptions_from_settings_profile(study: EconomicsStudy) -> EconomicsAssumptions | None: 

136 """Return an unsaved legacy assumptions row for serializers backed by the selected profile.""" 

137 

138 profile = get_settings_profile(study) 

139 if profile is None: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 return None 

141 return EconomicsAssumptions( 

142 flowsheet_state=study.flowsheet_state, 

143 study=study, 

144 currency=profile.currency, 

145 location=profile.location, 

146 basis_date=profile.basis_date, 

147 discount_rate_percent=profile.discount_rate_percent, 

148 project_lifetime_years=profile.project_lifetime_years, 

149 inflation_method=profile.inflation_method, 

150 annual_operating_hours=profile.annual_operating_hours, 

151 tax_rate_percent=profile.tax_rate_percent, 

152 depreciation_enabled=profile.depreciation_enabled, 

153 default_depreciation_life_years=profile.default_depreciation_life_years, 

154 default_depreciation_salvage_percent=profile.default_depreciation_salvage_percent, 

155 contingency_percent=profile.contingency_percent, 

156 electrical_upgrade_rate_amount=profile.electrical_upgrade_rate_amount, 

157 default_lang_factor=profile.default_lang_factor, 

158 capital_index_series=profile.capital_index_series, 

159 operating_index_series=profile.operating_index_series, 

160 default_rate_overrides=profile.default_rate_overrides, 

161 notes=profile.notes, 

162 ) 

163 

164 

165def baseline_from_settings_profile(study: EconomicsStudy) -> EconomicsBaseline | None: 

166 """Return an unsaved legacy baseline row for serializers backed by the selected profile.""" 

167 

168 profile = get_settings_profile(study) 

169 if profile is None: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 return None 

171 return EconomicsBaseline( 

172 flowsheet_state=study.flowsheet_state, 

173 study=study, 

174 manual_capex=profile.manual_capex, 

175 manual_annual_opex=profile.manual_annual_opex, 

176 annual_heat_basis_mode=profile.annual_heat_basis_mode, 

177 manual_annual_heat_basis=profile.manual_annual_heat_basis, 

178 manual_annual_heat_basis_unit=profile.manual_annual_heat_basis_unit, 

179 average_power_input=profile.average_power_input, 

180 average_power_unit=profile.average_power_unit, 

181 residual_value=profile.residual_value, 

182 notes=profile.baseline_notes, 

183 ) 

184 

185 

186def _profile_for_legacy_endpoint_update(study: EconomicsStudy) -> EconomicsSettingsProfile: 

187 profile = get_settings_profile(study) 

188 if profile is not None: 188 ↛ 190line 188 didn't jump to line 190 because the condition on line 188 was always true

189 return profile 

190 profile = get_or_create_default_settings_profile_for_project(study.flowsheet_state.flowsheet.project) 

191 study.settings_profile = profile 

192 study.save(update_fields=["settings_profile"]) 

193 return profile 

194 

195 

196def _update_settings_profile( 

197 profile: EconomicsSettingsProfile, 

198 values: SettingsProfileValues, 

199) -> EconomicsSettingsProfile: 

200 if not values: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true

201 return profile 

202 for field_name, value in values.items(): 

203 setattr(profile, field_name, value) 

204 profile.save(update_fields=list(values.keys())) 

205 return profile 

206 

207 

208def _filtered_legacy_values( 

209 values: SettingsProfileValues, 

210 field_names: Iterable[str] | None, 

211 *, 

212 field_map: dict[str, str] | None = None, 

213) -> SettingsProfileValues: 

214 if field_names is None: 

215 return values 

216 mapped_names = { 

217 (field_map or {}).get(field_name, field_name) 

218 for field_name in field_names 

219 if field_name not in {"study", "flowsheet"} 

220 } 

221 return { 

222 field_name: value 

223 for field_name, value in values.items() 

224 if field_name in mapped_names 

225 } 

226 

227 

228def _profile_values(profile: EconomicsSettingsProfile) -> SettingsProfileValues: 

229 return { 

230 "currency": profile.currency, 

231 "location": profile.location, 

232 "basis_date": profile.basis_date, 

233 "discount_rate_percent": profile.discount_rate_percent, 

234 "project_lifetime_years": profile.project_lifetime_years, 

235 "inflation_method": profile.inflation_method, 

236 "annual_operating_hours": profile.annual_operating_hours, 

237 "tax_rate_percent": profile.tax_rate_percent, 

238 "depreciation_enabled": profile.depreciation_enabled, 

239 "default_depreciation_life_years": profile.default_depreciation_life_years, 

240 "default_depreciation_salvage_percent": profile.default_depreciation_salvage_percent, 

241 "contingency_percent": profile.contingency_percent, 

242 "electrical_upgrade_rate_amount": profile.electrical_upgrade_rate_amount, 

243 "default_lang_factor": profile.default_lang_factor, 

244 "capital_index_series": profile.capital_index_series, 

245 "operating_index_series": profile.operating_index_series, 

246 "default_rate_overrides": profile.default_rate_overrides, 

247 "manual_capex": profile.manual_capex, 

248 "manual_annual_opex": profile.manual_annual_opex, 

249 "annual_heat_basis_mode": profile.annual_heat_basis_mode, 

250 "manual_annual_heat_basis": profile.manual_annual_heat_basis, 

251 "manual_annual_heat_basis_unit": profile.manual_annual_heat_basis_unit, 

252 "average_power_input": profile.average_power_input, 

253 "average_power_unit": profile.average_power_unit, 

254 "residual_value": profile.residual_value, 

255 "notes": profile.notes, 

256 "baseline_notes": profile.baseline_notes, 

257 } 

258 

259 

260def _assumption_values(assumptions: EconomicsAssumptions) -> SettingsProfileValues: 

261 return { 

262 "currency": assumptions.currency, 

263 "location": assumptions.location, 

264 "basis_date": assumptions.basis_date, 

265 "discount_rate_percent": assumptions.discount_rate_percent, 

266 "project_lifetime_years": assumptions.project_lifetime_years, 

267 "inflation_method": assumptions.inflation_method, 

268 "annual_operating_hours": assumptions.annual_operating_hours, 

269 "tax_rate_percent": assumptions.tax_rate_percent, 

270 "depreciation_enabled": assumptions.depreciation_enabled, 

271 "default_depreciation_life_years": assumptions.default_depreciation_life_years, 

272 "default_depreciation_salvage_percent": assumptions.default_depreciation_salvage_percent, 

273 "contingency_percent": assumptions.contingency_percent, 

274 "electrical_upgrade_rate_amount": assumptions.electrical_upgrade_rate_amount, 

275 "default_lang_factor": assumptions.default_lang_factor, 

276 "capital_index_series": assumptions.capital_index_series, 

277 "operating_index_series": assumptions.operating_index_series, 

278 "default_rate_overrides": assumptions.default_rate_overrides, 

279 "notes": assumptions.notes, 

280 } 

281 

282 

283def _baseline_values(baseline: EconomicsBaseline) -> SettingsProfileValues: 

284 return { 

285 "manual_capex": baseline.manual_capex, 

286 "manual_annual_opex": baseline.manual_annual_opex, 

287 "annual_heat_basis_mode": baseline.annual_heat_basis_mode, 

288 "manual_annual_heat_basis": baseline.manual_annual_heat_basis, 

289 "manual_annual_heat_basis_unit": baseline.manual_annual_heat_basis_unit, 

290 "average_power_input": baseline.average_power_input, 

291 "average_power_unit": baseline.average_power_unit, 

292 "residual_value": baseline.residual_value, 

293 "baseline_notes": baseline.notes, 

294 }