Coverage for backend/django/Economics/settings_profiles/models.py: 92%
133 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
1from decimal import Decimal
3from django.core.exceptions import ValidationError
4from django.db import models
6from core.managers import AccessControlManager, AllFlowsheetStatesManager, ProjectAccessControlManager
7from Economics.shared.choices import AnnualHeatBasisMode, AnnualHeatBasisUnit, AveragePowerUnit
8from Economics.shared.model_base import FlowsheetScopedEconomicsModel
11class EconomicsSettingsProfile(models.Model):
12 """Reusable economics settings and baseline defaults for a project.
14 Profiles are selected by studies but are owned by the project, allowing
15 multiple studies to share the same assumptions without duplicating setup
16 rows for every study.
17 """
19 project = models.ForeignKey(
20 "core_auxiliary.Project",
21 on_delete=models.CASCADE,
22 related_name="economics_settings_profiles",
23 )
24 name = models.CharField(max_length=128)
25 is_default = models.BooleanField(default=False)
26 currency = models.CharField(max_length=3, default="NZD")
27 location = models.CharField(max_length=128, blank=True)
28 basis_date = models.DateField(null=True, blank=True)
29 discount_rate_percent = models.DecimalField(max_digits=7, decimal_places=4, null=True, blank=True, default="10.0000")
30 project_lifetime_years = models.PositiveIntegerField(null=True, blank=True, default=25)
31 inflation_method = models.CharField(max_length=64, blank=True, default="stats_nz_cpi_all_groups")
32 annual_operating_hours = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
33 tax_rate_percent = models.DecimalField(
34 max_digits=7,
35 decimal_places=4,
36 null=True,
37 blank=True,
38 default=Decimal("0.0000"),
39 help_text="Corporate tax rate applied to before-tax annual savings and straight-line depreciation tax shield.",
40 )
41 depreciation_enabled = models.BooleanField(default=False)
42 default_depreciation_life_years = models.PositiveIntegerField(
43 null=True,
44 blank=True,
45 default=10,
46 help_text="Default straight-line depreciation life for included capital equipment.",
47 )
48 default_depreciation_salvage_percent = models.DecimalField(
49 max_digits=7,
50 decimal_places=4,
51 null=True,
52 blank=True,
53 default=Decimal("0.0000"),
54 help_text="Default non-depreciable residual percentage retained at the end of equipment life.",
55 )
56 contingency_percent = models.DecimalField(
57 max_digits=7,
58 decimal_places=4,
59 null=True,
60 blank=True,
61 default=Decimal("0.0000"),
62 help_text="Capital-cost contingency applied as a percentage uplift after escalation and installation factors.",
63 )
64 electrical_upgrade_rate_amount = models.DecimalField(
65 max_digits=18,
66 decimal_places=4,
67 null=True,
68 blank=True,
69 default=Decimal("0.0000"),
70 help_text="Electrical-upgrade capital rate applied to peak electrical demand.",
71 )
72 electrical_upgrade_rate_unit = models.CharField(max_length=16, default="NZD/kW", editable=False)
73 default_lang_factor = models.DecimalField(max_digits=10, decimal_places=6, default=Decimal("3.000000"))
74 capital_index_series = models.ForeignKey(
75 "CostIndexSeries",
76 on_delete=models.SET_NULL,
77 related_name="capital_settings_profiles",
78 null=True,
79 blank=True,
80 )
81 operating_index_series = models.ForeignKey(
82 "CostIndexSeries",
83 on_delete=models.SET_NULL,
84 related_name="operating_settings_profiles",
85 null=True,
86 blank=True,
87 )
88 default_rate_overrides = models.JSONField(
89 default=dict,
90 blank=True,
91 help_text="Utility and maintenance default-rate selections keyed by default rate type.",
92 )
93 manual_capex = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
94 manual_annual_opex = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
95 annual_heat_basis_mode = models.CharField(
96 max_length=16,
97 choices=AnnualHeatBasisMode.choices,
98 default=AnnualHeatBasisMode.EXPLICIT,
99 )
100 manual_annual_heat_basis = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
101 manual_annual_heat_basis_unit = models.CharField(
102 max_length=16,
103 choices=AnnualHeatBasisUnit.choices,
104 default=AnnualHeatBasisUnit.GJ_PER_YEAR,
105 )
106 average_power_input = models.DecimalField(max_digits=18, decimal_places=8, null=True, blank=True)
107 average_power_unit = models.CharField(
108 max_length=16,
109 choices=AveragePowerUnit.choices,
110 default=AveragePowerUnit.GJ_PER_HOUR,
111 )
112 residual_value = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
113 notes = models.TextField(blank=True)
114 baseline_notes = models.TextField(blank=True)
115 created_at = models.DateTimeField(auto_now_add=True)
116 updated_at = models.DateTimeField(auto_now=True)
118 objects = ProjectAccessControlManager()
120 class Meta:
121 ordering = ["created_at"]
122 constraints = [
123 models.UniqueConstraint(
124 fields=["project", "name"],
125 name="unique_economics_settings_profile_name_per_project",
126 ),
127 models.UniqueConstraint(
128 fields=["project"],
129 condition=models.Q(is_default=True),
130 name="unique_default_economics_settings_profile_per_project",
131 ),
132 ]
134 def clean(self):
135 super().clean()
136 errors = {}
137 if self.tax_rate_percent is not None and not Decimal("0") <= self.tax_rate_percent <= Decimal("100"): 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 errors["tax_rate_percent"] = "Tax rate must be between 0 and 100 percent."
139 if self.annual_operating_hours is not None and self.annual_operating_hours <= 0: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 errors["annual_operating_hours"] = "Annual operating hours must be positive."
141 if self.depreciation_enabled:
142 if self.default_depreciation_life_years in (None, 0): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 errors["default_depreciation_life_years"] = "Default equipment life is required when depreciation is enabled."
144 if ( 144 ↛ 148line 144 didn't jump to line 148 because the condition on line 144 was never true
145 self.default_depreciation_salvage_percent is not None
146 and not Decimal("0") <= self.default_depreciation_salvage_percent <= Decimal("100")
147 ):
148 errors["default_depreciation_salvage_percent"] = "Default residual value must be between 0 and 100 percent."
149 if errors: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 raise ValidationError(errors)
152 def save(self, *args, **kwargs):
153 self.full_clean()
154 return super().save(*args, **kwargs)
156 def __str__(self):
157 return self.name
159class EconomicsAssumptions(FlowsheetScopedEconomicsModel):
160 same_flowsheet_fields = ("study",)
162 flowsheet_state = models.ForeignKey("core_auxiliary.FlowsheetState", on_delete=models.CASCADE, related_name="economics_assumptions")
163 study = models.OneToOneField("EconomicsStudy", on_delete=models.CASCADE, related_name="assumptions")
164 currency = models.CharField(max_length=3, default="NZD")
165 location = models.CharField(max_length=128, blank=True)
166 basis_date = models.DateField(null=True, blank=True)
167 discount_rate_percent = models.DecimalField(max_digits=7, decimal_places=4, null=True, blank=True, default="10.0000")
168 project_lifetime_years = models.PositiveIntegerField(null=True, blank=True, default=25)
169 inflation_method = models.CharField(max_length=64, blank=True, default="stats_nz_cpi_all_groups")
170 annual_operating_hours = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
171 tax_rate_percent = models.DecimalField(
172 max_digits=7,
173 decimal_places=4,
174 null=True,
175 blank=True,
176 default=Decimal("0.0000"),
177 help_text="Corporate tax rate applied to before-tax annual savings and straight-line depreciation tax shield.",
178 )
179 depreciation_enabled = models.BooleanField(default=False)
180 default_depreciation_life_years = models.PositiveIntegerField(
181 null=True,
182 blank=True,
183 default=10,
184 help_text="Default straight-line equipment life for depreciable capital lines.",
185 )
186 default_depreciation_salvage_percent = models.DecimalField(
187 max_digits=7,
188 decimal_places=4,
189 null=True,
190 blank=True,
191 default=Decimal("0.0000"),
192 help_text="Default non-depreciable residual percentage retained at the end of equipment life.",
193 )
194 contingency_percent = models.DecimalField(
195 max_digits=7,
196 decimal_places=4,
197 null=True,
198 blank=True,
199 default=Decimal("0.0000"),
200 help_text="Capital-cost contingency applied as a percentage uplift after escalation and installation factors.",
201 )
202 electrical_upgrade_rate_amount = models.DecimalField(
203 max_digits=18,
204 decimal_places=4,
205 null=True,
206 blank=True,
207 default=Decimal("0.0000"),
208 help_text="Electrical-upgrade capital rate applied to peak electrical demand.",
209 )
210 electrical_upgrade_rate_unit = models.CharField(max_length=16, default="NZD/kW", editable=False)
211 default_lang_factor = models.DecimalField(max_digits=10, decimal_places=6, default=Decimal("3.000000"))
212 capital_index_series = models.ForeignKey(
213 "CostIndexSeries",
214 on_delete=models.SET_NULL,
215 related_name="capital_assumption_sets",
216 null=True,
217 blank=True,
218 )
219 operating_index_series = models.ForeignKey(
220 "CostIndexSeries",
221 on_delete=models.SET_NULL,
222 related_name="operating_assumption_sets",
223 null=True,
224 blank=True,
225 )
226 default_rate_overrides = models.JSONField(
227 default=dict,
228 blank=True,
229 help_text="Study-level utility and maintenance default-rate selections keyed by default rate type.",
230 )
231 notes = models.TextField(blank=True)
232 created_at = models.DateTimeField(auto_now_add=True)
233 updated_at = models.DateTimeField(auto_now=True)
235 objects = AccessControlManager()
236 all_states = AllFlowsheetStatesManager()
238 class Meta:
239 verbose_name_plural = "economics assumptions"
241 def clean(self):
242 super().clean()
243 errors = {}
244 if self.tax_rate_percent is not None and not Decimal("0") <= self.tax_rate_percent <= Decimal("100"):
245 errors["tax_rate_percent"] = "Tax rate must be between 0 and 100 percent."
246 if self.annual_operating_hours is not None and self.annual_operating_hours <= 0: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true
247 errors["annual_operating_hours"] = "Annual operating hours must be positive."
248 if self.depreciation_enabled:
249 if self.default_depreciation_life_years in (None, 0):
250 errors["default_depreciation_life_years"] = "Default equipment life is required when depreciation is enabled."
251 if (
252 self.default_depreciation_salvage_percent is not None
253 and not Decimal("0") <= self.default_depreciation_salvage_percent <= Decimal("100")
254 ):
255 errors["default_depreciation_salvage_percent"] = "Default residual value must be between 0 and 100 percent."
256 if errors:
257 raise ValidationError(errors)
259 def __str__(self):
260 return f"Assumptions for {self.study.name}"
262class EconomicsBaseline(FlowsheetScopedEconomicsModel):
263 same_flowsheet_fields = ("study",)
265 flowsheet_state = models.ForeignKey("core_auxiliary.FlowsheetState", on_delete=models.CASCADE, related_name="economics_baselines")
266 study = models.OneToOneField("EconomicsStudy", on_delete=models.CASCADE, related_name="baseline")
267 manual_capex = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
268 manual_annual_opex = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
269 annual_heat_basis_mode = models.CharField(
270 max_length=16,
271 choices=AnnualHeatBasisMode.choices,
272 default=AnnualHeatBasisMode.EXPLICIT,
273 )
274 manual_annual_heat_basis = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
275 manual_annual_heat_basis_unit = models.CharField(
276 max_length=16,
277 choices=AnnualHeatBasisUnit.choices,
278 default=AnnualHeatBasisUnit.GJ_PER_YEAR,
279 )
280 average_power_input = models.DecimalField(max_digits=18, decimal_places=8, null=True, blank=True)
281 average_power_unit = models.CharField(
282 max_length=16,
283 choices=AveragePowerUnit.choices,
284 default=AveragePowerUnit.GJ_PER_HOUR,
285 )
286 manual_currency = models.CharField(max_length=3, blank=True)
287 manual_basis_date = models.DateField(null=True, blank=True)
288 inherit_project_lifetime = models.BooleanField(default=True)
289 project_lifetime_years = models.PositiveIntegerField(null=True, blank=True)
290 inherit_discount_rate = models.BooleanField(default=True)
291 discount_rate_percent = models.DecimalField(max_digits=7, decimal_places=4, null=True, blank=True)
292 residual_value = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
293 notes = models.TextField(blank=True)
294 created_at = models.DateTimeField(auto_now_add=True)
295 updated_at = models.DateTimeField(auto_now=True)
297 objects = AccessControlManager()
298 all_states = AllFlowsheetStatesManager()
300 def __str__(self):
301 return f"{self.study.name} manual baseline"