Coverage for backend/django/Economics/settings_profiles/services/settings_profiles.py: 75%
113 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
1from __future__ import annotations
3from datetime import date
4from decimal import Decimal
5from typing import Any, Iterable, TypedDict
7from django.core.exceptions import ObjectDoesNotExist
8from django.db import transaction
10from core.auxiliary.models import Flowsheet
11from Economics.reference_data.models import CostIndexSeries
12from Economics.settings_profiles.models import EconomicsAssumptions, EconomicsBaseline, EconomicsSettingsProfile
13from Economics.studies.models import EconomicsStudy
16class SettingsProfileValues(TypedDict, total=False):
17 currency: str
18 location: str
19 basis_date: date | None
20 discount_rate_percent: Decimal | None
21 project_lifetime_years: int | None
22 inflation_method: str
23 annual_operating_hours: Decimal | None
24 tax_rate_percent: Decimal | None
25 depreciation_enabled: bool
26 default_depreciation_life_years: int | None
27 default_depreciation_salvage_percent: Decimal | None
28 contingency_percent: Decimal | None
29 electrical_upgrade_rate_amount: Decimal | None
30 default_lang_factor: Decimal
31 capital_index_series: CostIndexSeries | None
32 operating_index_series: CostIndexSeries | None
33 default_rate_overrides: dict[str, Any]
34 manual_capex: Decimal | None
35 manual_annual_opex: Decimal | None
36 annual_heat_basis_mode: str
37 manual_annual_heat_basis: Decimal | None
38 manual_annual_heat_basis_unit: str
39 average_power_input: Decimal | None
40 average_power_unit: str
41 residual_value: Decimal | None
42 notes: str
43 baseline_notes: str
46def get_settings_profile(study: EconomicsStudy) -> EconomicsSettingsProfile | None:
47 """Return the active profile selected by a study, if configured."""
49 try:
50 return study.settings_profile
51 except ObjectDoesNotExist:
52 return None
55def default_settings_profile_for_flowsheet(flowsheet: Flowsheet) -> EconomicsSettingsProfile | None:
56 return (
57 EconomicsSettingsProfile.objects.filter(flowsheet=flowsheet, is_default=True)
58 .order_by("created_at", "pk")
59 .first()
60 )
63def get_or_create_default_settings_profile(flowsheet: Flowsheet) -> EconomicsSettingsProfile:
64 profile = default_settings_profile_for_flowsheet(flowsheet)
65 if profile is not None:
66 return profile
67 return EconomicsSettingsProfile.objects.create(
68 flowsheet=flowsheet,
69 name="Default study profile",
70 is_default=True,
71 )
74@transaction.atomic
75def create_settings_profile_copy(
76 *,
77 source: EconomicsSettingsProfile,
78 name: str,
79 is_default: bool = False,
80) -> EconomicsSettingsProfile:
81 """Copy a settings profile inside the same flowsheet with a new name."""
83 if is_default: 83 ↛ 84line 83 didn't jump to line 84 because the condition on line 83 was never true
84 EconomicsSettingsProfile.objects.filter(flowsheet=source.flowsheet, is_default=True).update(is_default=False)
85 values = _profile_values(source)
86 return EconomicsSettingsProfile.objects.create(
87 flowsheet=source.flowsheet,
88 name=name,
89 is_default=is_default,
90 **values,
91 )
94def settings_profile_from_legacy_study(study: EconomicsStudy, *, name: str, is_default: bool) -> EconomicsSettingsProfile:
95 """Build a profile from the former per-study assumptions/baseline rows."""
97 values: SettingsProfileValues = {}
98 try:
99 assumptions = study.assumptions
100 except ObjectDoesNotExist:
101 assumptions = None
102 if assumptions is not None:
103 values.update(_assumption_values(assumptions))
105 try:
106 baseline = study.baseline
107 except ObjectDoesNotExist:
108 baseline = None
109 if baseline is not None:
110 values.update(_baseline_values(baseline))
112 return EconomicsSettingsProfile.objects.create(
113 flowsheet=study.flowsheet,
114 name=name,
115 is_default=is_default,
116 **values,
117 )
120@transaction.atomic
121def update_settings_profile_from_assumptions(
122 assumptions: EconomicsAssumptions,
123 *,
124 field_names: Iterable[str] | None = None,
125) -> EconomicsSettingsProfile:
126 """Apply legacy assumptions endpoint values to the study's selected profile."""
128 return _update_settings_profile(
129 _profile_for_legacy_endpoint_update(assumptions.study),
130 _filtered_legacy_values(_assumption_values(assumptions), field_names),
131 )
134@transaction.atomic
135def update_settings_profile_from_baseline(
136 baseline: EconomicsBaseline,
137 *,
138 field_names: Iterable[str] | None = None,
139) -> EconomicsSettingsProfile:
140 """Apply legacy baseline endpoint values to the study's selected profile."""
142 return _update_settings_profile(
143 _profile_for_legacy_endpoint_update(baseline.study),
144 _filtered_legacy_values(
145 _baseline_values(baseline),
146 field_names,
147 field_map={"notes": "baseline_notes"},
148 ),
149 )
152def assumptions_from_settings_profile(study: EconomicsStudy) -> EconomicsAssumptions | None:
153 """Return an unsaved legacy assumptions row for serializers backed by the selected profile."""
155 profile = get_settings_profile(study)
156 if profile is None: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 return None
158 return EconomicsAssumptions(
159 flowsheet=study.flowsheet,
160 study=study,
161 currency=profile.currency,
162 location=profile.location,
163 basis_date=profile.basis_date,
164 discount_rate_percent=profile.discount_rate_percent,
165 project_lifetime_years=profile.project_lifetime_years,
166 inflation_method=profile.inflation_method,
167 annual_operating_hours=profile.annual_operating_hours,
168 tax_rate_percent=profile.tax_rate_percent,
169 depreciation_enabled=profile.depreciation_enabled,
170 default_depreciation_life_years=profile.default_depreciation_life_years,
171 default_depreciation_salvage_percent=profile.default_depreciation_salvage_percent,
172 contingency_percent=profile.contingency_percent,
173 electrical_upgrade_rate_amount=profile.electrical_upgrade_rate_amount,
174 default_lang_factor=profile.default_lang_factor,
175 capital_index_series=profile.capital_index_series,
176 operating_index_series=profile.operating_index_series,
177 default_rate_overrides=profile.default_rate_overrides,
178 notes=profile.notes,
179 )
182def baseline_from_settings_profile(study: EconomicsStudy) -> EconomicsBaseline | None:
183 """Return an unsaved legacy baseline row for serializers backed by the selected profile."""
185 profile = get_settings_profile(study)
186 if profile is None: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 return None
188 return EconomicsBaseline(
189 flowsheet=study.flowsheet,
190 study=study,
191 manual_capex=profile.manual_capex,
192 manual_annual_opex=profile.manual_annual_opex,
193 annual_heat_basis_mode=profile.annual_heat_basis_mode,
194 manual_annual_heat_basis=profile.manual_annual_heat_basis,
195 manual_annual_heat_basis_unit=profile.manual_annual_heat_basis_unit,
196 average_power_input=profile.average_power_input,
197 average_power_unit=profile.average_power_unit,
198 residual_value=profile.residual_value,
199 notes=profile.baseline_notes,
200 )
203def _profile_for_legacy_endpoint_update(study: EconomicsStudy) -> EconomicsSettingsProfile:
204 profile = get_settings_profile(study)
205 if profile is not None: 205 ↛ 207line 205 didn't jump to line 207 because the condition on line 205 was always true
206 return profile
207 profile = get_or_create_default_settings_profile(study.flowsheet)
208 study.settings_profile = profile
209 study.save(update_fields=["settings_profile"])
210 return profile
213def _update_settings_profile(
214 profile: EconomicsSettingsProfile,
215 values: SettingsProfileValues,
216) -> EconomicsSettingsProfile:
217 if not values: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 return profile
219 for field_name, value in values.items():
220 setattr(profile, field_name, value)
221 profile.save(update_fields=list(values.keys()))
222 return profile
225def _filtered_legacy_values(
226 values: SettingsProfileValues,
227 field_names: Iterable[str] | None,
228 *,
229 field_map: dict[str, str] | None = None,
230) -> SettingsProfileValues:
231 if field_names is None:
232 return values
233 mapped_names = {
234 (field_map or {}).get(field_name, field_name)
235 for field_name in field_names
236 if field_name not in {"study", "flowsheet"}
237 }
238 return {
239 field_name: value
240 for field_name, value in values.items()
241 if field_name in mapped_names
242 }
245def _profile_values(profile: EconomicsSettingsProfile) -> SettingsProfileValues:
246 return {
247 "currency": profile.currency,
248 "location": profile.location,
249 "basis_date": profile.basis_date,
250 "discount_rate_percent": profile.discount_rate_percent,
251 "project_lifetime_years": profile.project_lifetime_years,
252 "inflation_method": profile.inflation_method,
253 "annual_operating_hours": profile.annual_operating_hours,
254 "tax_rate_percent": profile.tax_rate_percent,
255 "depreciation_enabled": profile.depreciation_enabled,
256 "default_depreciation_life_years": profile.default_depreciation_life_years,
257 "default_depreciation_salvage_percent": profile.default_depreciation_salvage_percent,
258 "contingency_percent": profile.contingency_percent,
259 "electrical_upgrade_rate_amount": profile.electrical_upgrade_rate_amount,
260 "default_lang_factor": profile.default_lang_factor,
261 "capital_index_series": profile.capital_index_series,
262 "operating_index_series": profile.operating_index_series,
263 "default_rate_overrides": profile.default_rate_overrides,
264 "manual_capex": profile.manual_capex,
265 "manual_annual_opex": profile.manual_annual_opex,
266 "annual_heat_basis_mode": profile.annual_heat_basis_mode,
267 "manual_annual_heat_basis": profile.manual_annual_heat_basis,
268 "manual_annual_heat_basis_unit": profile.manual_annual_heat_basis_unit,
269 "average_power_input": profile.average_power_input,
270 "average_power_unit": profile.average_power_unit,
271 "residual_value": profile.residual_value,
272 "notes": profile.notes,
273 "baseline_notes": profile.baseline_notes,
274 }
277def _assumption_values(assumptions: EconomicsAssumptions) -> SettingsProfileValues:
278 return {
279 "currency": assumptions.currency,
280 "location": assumptions.location,
281 "basis_date": assumptions.basis_date,
282 "discount_rate_percent": assumptions.discount_rate_percent,
283 "project_lifetime_years": assumptions.project_lifetime_years,
284 "inflation_method": assumptions.inflation_method,
285 "annual_operating_hours": assumptions.annual_operating_hours,
286 "tax_rate_percent": assumptions.tax_rate_percent,
287 "depreciation_enabled": assumptions.depreciation_enabled,
288 "default_depreciation_life_years": assumptions.default_depreciation_life_years,
289 "default_depreciation_salvage_percent": assumptions.default_depreciation_salvage_percent,
290 "contingency_percent": assumptions.contingency_percent,
291 "electrical_upgrade_rate_amount": assumptions.electrical_upgrade_rate_amount,
292 "default_lang_factor": assumptions.default_lang_factor,
293 "capital_index_series": assumptions.capital_index_series,
294 "operating_index_series": assumptions.operating_index_series,
295 "default_rate_overrides": assumptions.default_rate_overrides,
296 "notes": assumptions.notes,
297 }
300def _baseline_values(baseline: EconomicsBaseline) -> SettingsProfileValues:
301 return {
302 "manual_capex": baseline.manual_capex,
303 "manual_annual_opex": baseline.manual_annual_opex,
304 "annual_heat_basis_mode": baseline.annual_heat_basis_mode,
305 "manual_annual_heat_basis": baseline.manual_annual_heat_basis,
306 "manual_annual_heat_basis_unit": baseline.manual_annual_heat_basis_unit,
307 "average_power_input": baseline.average_power_input,
308 "average_power_unit": baseline.average_power_unit,
309 "residual_value": baseline.residual_value,
310 "baseline_notes": baseline.notes,
311 }