Coverage for backend/django/Economics/settings_profiles/models.py: 91%
128 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 decimal import Decimal
3from django.core.exceptions import ValidationError
4from django.db import models
6from core.managers import AccessControlManager
7from Economics.shared.choices import AnnualHeatBasisMode, AnnualHeatBasisUnit, AveragePowerUnit
8from Economics.shared.model_base import FlowsheetScopedEconomicsModel
11class EconomicsSettingsProfile(FlowsheetScopedEconomicsModel):
12 """Reusable economics settings and baseline defaults for a flowsheet.
14 Profiles are selected by studies but are owned by the flowsheet, allowing
15 multiple studies to share the same assumptions without duplicating setup
16 rows for every study.
17 """
19 flowsheet = models.ForeignKey(
20 "core_auxiliary.Flowsheet",
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 = AccessControlManager()
120 class Meta:
121 ordering = ["created_at"]
122 constraints = [
123 models.UniqueConstraint(
124 fields=["flowsheet", "name"],
125 name="unique_economics_settings_profile_name_per_flowsheet",
126 ),
127 models.UniqueConstraint(
128 fields=["flowsheet"],
129 condition=models.Q(is_default=True),
130 name="unique_default_economics_settings_profile_per_flowsheet",
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 __str__(self):
153 return self.name
155class EconomicsAssumptions(FlowsheetScopedEconomicsModel):
156 same_flowsheet_fields = ("study",)
158 flowsheet = models.ForeignKey("core_auxiliary.Flowsheet", on_delete=models.CASCADE, related_name="economics_assumptions")
159 study = models.OneToOneField("EconomicsStudy", on_delete=models.CASCADE, related_name="assumptions")
160 currency = models.CharField(max_length=3, default="NZD")
161 location = models.CharField(max_length=128, blank=True)
162 basis_date = models.DateField(null=True, blank=True)
163 discount_rate_percent = models.DecimalField(max_digits=7, decimal_places=4, null=True, blank=True, default="10.0000")
164 project_lifetime_years = models.PositiveIntegerField(null=True, blank=True, default=25)
165 inflation_method = models.CharField(max_length=64, blank=True, default="stats_nz_cpi_all_groups")
166 annual_operating_hours = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
167 tax_rate_percent = models.DecimalField(
168 max_digits=7,
169 decimal_places=4,
170 null=True,
171 blank=True,
172 default=Decimal("0.0000"),
173 help_text="Corporate tax rate applied to before-tax annual savings and straight-line depreciation tax shield.",
174 )
175 depreciation_enabled = models.BooleanField(default=False)
176 default_depreciation_life_years = models.PositiveIntegerField(
177 null=True,
178 blank=True,
179 default=10,
180 help_text="Default straight-line equipment life for depreciable capital lines.",
181 )
182 default_depreciation_salvage_percent = models.DecimalField(
183 max_digits=7,
184 decimal_places=4,
185 null=True,
186 blank=True,
187 default=Decimal("0.0000"),
188 help_text="Default non-depreciable residual percentage retained at the end of equipment life.",
189 )
190 contingency_percent = models.DecimalField(
191 max_digits=7,
192 decimal_places=4,
193 null=True,
194 blank=True,
195 default=Decimal("0.0000"),
196 help_text="Capital-cost contingency applied as a percentage uplift after escalation and installation factors.",
197 )
198 electrical_upgrade_rate_amount = models.DecimalField(
199 max_digits=18,
200 decimal_places=4,
201 null=True,
202 blank=True,
203 default=Decimal("0.0000"),
204 help_text="Electrical-upgrade capital rate applied to peak electrical demand.",
205 )
206 electrical_upgrade_rate_unit = models.CharField(max_length=16, default="NZD/kW", editable=False)
207 default_lang_factor = models.DecimalField(max_digits=10, decimal_places=6, default=Decimal("3.000000"))
208 capital_index_series = models.ForeignKey(
209 "CostIndexSeries",
210 on_delete=models.SET_NULL,
211 related_name="capital_assumption_sets",
212 null=True,
213 blank=True,
214 )
215 operating_index_series = models.ForeignKey(
216 "CostIndexSeries",
217 on_delete=models.SET_NULL,
218 related_name="operating_assumption_sets",
219 null=True,
220 blank=True,
221 )
222 default_rate_overrides = models.JSONField(
223 default=dict,
224 blank=True,
225 help_text="Study-level utility and maintenance default-rate selections keyed by default rate type.",
226 )
227 notes = models.TextField(blank=True)
228 created_at = models.DateTimeField(auto_now_add=True)
229 updated_at = models.DateTimeField(auto_now=True)
231 objects = AccessControlManager()
233 class Meta:
234 verbose_name_plural = "economics assumptions"
236 def clean(self):
237 super().clean()
238 errors = {}
239 if self.tax_rate_percent is not None and not Decimal("0") <= self.tax_rate_percent <= Decimal("100"):
240 errors["tax_rate_percent"] = "Tax rate must be between 0 and 100 percent."
241 if self.annual_operating_hours is not None and self.annual_operating_hours <= 0: 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true
242 errors["annual_operating_hours"] = "Annual operating hours must be positive."
243 if self.depreciation_enabled:
244 if self.default_depreciation_life_years in (None, 0):
245 errors["default_depreciation_life_years"] = "Default equipment life is required when depreciation is enabled."
246 if (
247 self.default_depreciation_salvage_percent is not None
248 and not Decimal("0") <= self.default_depreciation_salvage_percent <= Decimal("100")
249 ):
250 errors["default_depreciation_salvage_percent"] = "Default residual value must be between 0 and 100 percent."
251 if errors:
252 raise ValidationError(errors)
254 def __str__(self):
255 return f"Assumptions for {self.study.name}"
257class EconomicsBaseline(FlowsheetScopedEconomicsModel):
258 same_flowsheet_fields = ("study",)
260 flowsheet = models.ForeignKey("core_auxiliary.Flowsheet", on_delete=models.CASCADE, related_name="economics_baselines")
261 study = models.OneToOneField("EconomicsStudy", on_delete=models.CASCADE, related_name="baseline")
262 manual_capex = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
263 manual_annual_opex = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
264 annual_heat_basis_mode = models.CharField(
265 max_length=16,
266 choices=AnnualHeatBasisMode.choices,
267 default=AnnualHeatBasisMode.EXPLICIT,
268 )
269 manual_annual_heat_basis = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
270 manual_annual_heat_basis_unit = models.CharField(
271 max_length=16,
272 choices=AnnualHeatBasisUnit.choices,
273 default=AnnualHeatBasisUnit.GJ_PER_YEAR,
274 )
275 average_power_input = models.DecimalField(max_digits=18, decimal_places=8, null=True, blank=True)
276 average_power_unit = models.CharField(
277 max_length=16,
278 choices=AveragePowerUnit.choices,
279 default=AveragePowerUnit.GJ_PER_HOUR,
280 )
281 manual_currency = models.CharField(max_length=3, blank=True)
282 manual_basis_date = models.DateField(null=True, blank=True)
283 inherit_project_lifetime = models.BooleanField(default=True)
284 project_lifetime_years = models.PositiveIntegerField(null=True, blank=True)
285 inherit_discount_rate = models.BooleanField(default=True)
286 discount_rate_percent = models.DecimalField(max_digits=7, decimal_places=4, null=True, blank=True)
287 residual_value = models.DecimalField(max_digits=18, decimal_places=4, null=True, blank=True)
288 notes = models.TextField(blank=True)
289 created_at = models.DateTimeField(auto_now_add=True)
290 updated_at = models.DateTimeField(auto_now=True)
292 objects = AccessControlManager()
294 def __str__(self):
295 return f"{self.study.name} manual baseline"