Coverage for backend/django/Economics/studies/models.py: 91%

109 statements  

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

1import uuid 

2 

3from django.core.exceptions import ValidationError 

4from django.db import models 

5 

6from core.managers import AccessControlManager, AllFlowsheetStatesManager 

7from Economics.shared.choices import EconomicsBaselineMode, EconomicsScheduleMode 

8from Economics.shared.model_base import FlowsheetScopedEconomicsModel 

9 

10 

11class EconomicsStudy(FlowsheetScopedEconomicsModel): 

12 same_flowsheet_fields = ("schedule_scenario",) 

13 

14 flowsheet_state = models.ForeignKey("core_auxiliary.FlowsheetState", on_delete=models.CASCADE, related_name="economics_studies") 

15 lineage_id = models.UUIDField(default=uuid.uuid4, editable=False) 

16 settings_profile = models.ForeignKey( 

17 "EconomicsSettingsProfile", 

18 on_delete=models.SET_NULL, 

19 related_name="studies", 

20 null=True, 

21 blank=True, 

22 help_text="Project-scoped settings profile selected by this study.", 

23 ) 

24 schedule_mode = models.CharField( 

25 max_length=32, 

26 choices=EconomicsScheduleMode.choices, 

27 default=EconomicsScheduleMode.STEADY_STATE, 

28 ) 

29 schedule_scenario = models.ForeignKey( 

30 "core_auxiliary.Scenario", 

31 on_delete=models.SET_NULL, 

32 related_name="scheduled_economics_studies", 

33 null=True, 

34 blank=True, 

35 help_text="MSS time-series scenario used as this study's production schedule.", 

36 ) 

37 baseline_mode = models.CharField( 

38 max_length=16, 

39 choices=EconomicsBaselineMode.choices, 

40 default=EconomicsBaselineMode.MANUAL, 

41 help_text="Financial baseline source used when calculating baseline-dependent study metrics.", 

42 ) 

43 baseline_flowsheet_id = models.PositiveBigIntegerField( 

44 null=True, 

45 blank=True, 

46 help_text="Stable flowsheet identity for the selected baseline study.", 

47 ) 

48 baseline_study_lineage_id = models.UUIDField( 

49 null=True, 

50 blank=True, 

51 help_text="Logical study identity resolved in the target flowsheet's current state.", 

52 ) 

53 name = models.CharField(max_length=128) 

54 description = models.TextField(blank=True) 

55 created_at = models.DateTimeField(auto_now_add=True) 

56 updated_at = models.DateTimeField(auto_now=True) 

57 

58 objects = AccessControlManager() 

59 all_states = AllFlowsheetStatesManager() 

60 

61 class Meta: 

62 ordering = ["created_at"] 

63 constraints = [ 

64 models.UniqueConstraint( 

65 fields=["flowsheet_state", "name"], 

66 name="unique_economics_study_name_per_state", 

67 ), 

68 models.UniqueConstraint( 

69 fields=["flowsheet_state", "lineage_id"], 

70 name="unique_economics_study_lineage_per_state", 

71 ), 

72 ] 

73 

74 def __str__(self): 

75 return self.name 

76 

77 def clean(self): 

78 super().clean() 

79 if ( 79 ↛ 85line 79 didn't jump to line 85 because the condition on line 79 was never true

80 self.settings_profile_id is not None 

81 and self.flowsheet_state_id is not None 

82 and self.settings_profile.project_id 

83 != self.flowsheet_state.flowsheet.project_id 

84 ): 

85 raise ValidationError( 

86 {"settings_profile": "Referenced profile must belong to the study project."} 

87 ) 

88 from Economics.studies.services.study_configuration import ( 

89 StudyConfigurationValidationError, 

90 validate_study_configuration_for_model, 

91 ) 

92 

93 try: 

94 validate_study_configuration_for_model(self) 

95 except StudyConfigurationValidationError as exc: 

96 raise ValidationError(exc.errors) from exc 

97 

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

99 if ( 

100 self.baseline_mode == EconomicsBaselineMode.STUDY 

101 or self.schedule_mode in {EconomicsScheduleMode.SCENARIO, EconomicsScheduleMode.COMPOSITE} 

102 ): 

103 self.clean() 

104 super().save(*args, **kwargs) 

105 

106 

107class EconomicsStudyComparisonConfig(FlowsheetScopedEconomicsModel): 

108 """Saved comparison setup for one active economics study.""" 

109 

110 same_flowsheet_fields = ("active_study",) 

111 

112 flowsheet_state = models.ForeignKey( 

113 "core_auxiliary.FlowsheetState", 

114 on_delete=models.CASCADE, 

115 related_name="economics_study_comparison_configs", 

116 ) 

117 active_study = models.OneToOneField( 

118 "EconomicsStudy", 

119 on_delete=models.CASCADE, 

120 related_name="comparison_config", 

121 ) 

122 baseline_mode = models.CharField( 

123 max_length=16, 

124 choices=EconomicsBaselineMode.choices, 

125 default=EconomicsBaselineMode.STUDY, 

126 help_text="Baseline source mode used for this saved comparison.", 

127 ) 

128 baseline_flowsheet_id = models.PositiveBigIntegerField( 

129 null=True, 

130 blank=True, 

131 help_text="Stable flowsheet identity for the selected comparison baseline.", 

132 ) 

133 baseline_study_lineage_id = models.UUIDField( 

134 null=True, 

135 blank=True, 

136 help_text="Logical study identity for the selected comparison baseline.", 

137 ) 

138 created_at = models.DateTimeField(auto_now_add=True) 

139 updated_at = models.DateTimeField(auto_now=True) 

140 

141 objects = AccessControlManager() 

142 all_states = AllFlowsheetStatesManager() 

143 

144 class Meta: 

145 ordering = ["active_study_id"] 

146 

147 def __str__(self): 

148 return f"{self.active_study} comparison" 

149 

150 

151class EconomicsStudyComparisonSelection(FlowsheetScopedEconomicsModel): 

152 """Ordered saved reference to a comparison study, including display snapshots.""" 

153 

154 same_flowsheet_fields = ("config",) 

155 

156 flowsheet_state = models.ForeignKey( 

157 "core_auxiliary.FlowsheetState", 

158 on_delete=models.CASCADE, 

159 related_name="economics_study_comparison_selections", 

160 ) 

161 config = models.ForeignKey( 

162 "EconomicsStudyComparisonConfig", 

163 on_delete=models.CASCADE, 

164 related_name="selections", 

165 ) 

166 target_study_lineage_id = models.UUIDField() 

167 target_flowsheet_id = models.PositiveBigIntegerField() 

168 study_name_snapshot = models.CharField(max_length=128) 

169 flowsheet_name_snapshot = models.CharField(max_length=64) 

170 sort_order = models.PositiveIntegerField(default=0) 

171 created_at = models.DateTimeField(auto_now_add=True) 

172 updated_at = models.DateTimeField(auto_now=True) 

173 

174 objects = AccessControlManager() 

175 all_states = AllFlowsheetStatesManager() 

176 

177 class Meta: 

178 ordering = ["sort_order", "pk"] 

179 constraints = [ 

180 models.UniqueConstraint( 

181 fields=["config", "target_flowsheet_id", "target_study_lineage_id"], 

182 name="unique_comparison_selection_per_config_lineage", 

183 ), 

184 ] 

185 

186 def __str__(self): 

187 return self.study_name_snapshot 

188 

189 

190class EconomicsSchedulePlan(FlowsheetScopedEconomicsModel): 

191 """Weekly composite production schedule selected by an economics study.""" 

192 

193 same_flowsheet_fields = ("study",) 

194 

195 flowsheet_state = models.ForeignKey( 

196 "core_auxiliary.FlowsheetState", 

197 on_delete=models.CASCADE, 

198 related_name="economics_schedule_plans", 

199 ) 

200 study = models.OneToOneField( 

201 "EconomicsStudy", 

202 on_delete=models.CASCADE, 

203 related_name="schedule_plan", 

204 ) 

205 created_at = models.DateTimeField(auto_now_add=True) 

206 updated_at = models.DateTimeField(auto_now=True) 

207 

208 objects = AccessControlManager() 

209 all_states = AllFlowsheetStatesManager() 

210 

211 class Meta: 

212 ordering = ["study_id"] 

213 

214 def __str__(self): 

215 return f"{self.study} composite schedule" 

216 

217 

218class EconomicsScheduleRule(FlowsheetScopedEconomicsModel): 

219 """One source-scenario sequence placed at a weekly start time.""" 

220 

221 same_flowsheet_fields = ("plan", "source_scenario") 

222 

223 flowsheet_state = models.ForeignKey( 

224 "core_auxiliary.FlowsheetState", 

225 on_delete=models.CASCADE, 

226 related_name="economics_schedule_rules", 

227 ) 

228 plan = models.ForeignKey( 

229 "EconomicsSchedulePlan", 

230 on_delete=models.CASCADE, 

231 related_name="rules", 

232 ) 

233 source_scenario = models.ForeignKey( 

234 "core_auxiliary.Scenario", 

235 on_delete=models.SET_NULL, 

236 related_name="economics_schedule_rules", 

237 null=True, 

238 blank=True, 

239 help_text="Solved MSS time-series scenario assigned to this timetable block.", 

240 ) 

241 label = models.CharField(max_length=128, blank=True) 

242 days_mask = models.PositiveSmallIntegerField( 

243 help_text="Weekly day bitmask, Monday as bit 0 through Sunday as bit 6.", 

244 ) 

245 start_time = models.TimeField() 

246 sort_order = models.PositiveIntegerField(default=0) 

247 created_at = models.DateTimeField(auto_now_add=True) 

248 updated_at = models.DateTimeField(auto_now=True) 

249 

250 objects = AccessControlManager() 

251 all_states = AllFlowsheetStatesManager() 

252 

253 class Meta: 

254 ordering = ["sort_order", "pk"] 

255 

256 def __str__(self): 

257 return self.label or f"{self.plan} rule {self.pk}" 

258 

259 def clean(self): 

260 super().clean() 

261 errors: dict[str, str] = {} 

262 if not 1 <= self.days_mask <= 127: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 errors["days_mask"] = "Select at least one day for this schedule rule." 

264 if errors: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 raise ValidationError(errors)