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

1from decimal import Decimal 

2 

3from django.core.exceptions import ValidationError 

4from django.db import models 

5 

6from core.managers import AccessControlManager, AllFlowsheetStatesManager, ProjectAccessControlManager 

7from Economics.shared.choices import AnnualHeatBasisMode, AnnualHeatBasisUnit, AveragePowerUnit 

8from Economics.shared.model_base import FlowsheetScopedEconomicsModel 

9 

10 

11class EconomicsSettingsProfile(models.Model): 

12 """Reusable economics settings and baseline defaults for a project. 

13 

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

18 

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) 

117 

118 objects = ProjectAccessControlManager() 

119 

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 ] 

133 

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) 

151 

152 def save(self, *args, **kwargs): 

153 self.full_clean() 

154 return super().save(*args, **kwargs) 

155 

156 def __str__(self): 

157 return self.name 

158 

159class EconomicsAssumptions(FlowsheetScopedEconomicsModel): 

160 same_flowsheet_fields = ("study",) 

161 

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) 

234 

235 objects = AccessControlManager() 

236 all_states = AllFlowsheetStatesManager() 

237 

238 class Meta: 

239 verbose_name_plural = "economics assumptions" 

240 

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) 

258 

259 def __str__(self): 

260 return f"Assumptions for {self.study.name}" 

261 

262class EconomicsBaseline(FlowsheetScopedEconomicsModel): 

263 same_flowsheet_fields = ("study",) 

264 

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) 

296 

297 objects = AccessControlManager() 

298 all_states = AllFlowsheetStatesManager() 

299 

300 def __str__(self): 

301 return f"{self.study.name} manual baseline"