Coverage for backend/django/core/auxiliary/services/flowsheet_states/registry.py: 91%

115 statements  

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

1"""Explicit copy boundary for every flowsheet-state-owned model.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from enum import StrEnum 

7 

8from django.apps import apps 

9 

10 

11class CloneProfile(StrEnum): 

12 """Supported relational clone operations.""" 

13 

14 REVISION = "revision" 

15 RESTORE = "restore" 

16 NEW_FLOWSHEET = "new_flowsheet" 

17 MODULE_SUBTREE = "module_subtree" 

18 

19 

20class ClonePolicy(StrEnum): 

21 """Action taken for a model in one clone profile.""" 

22 

23 COPY = "copy" 

24 REBUILD = "rebuild" 

25 EXCLUDE = "exclude" 

26 

27 

28class CloneRegistryError(ValueError): 

29 """Raised when the declared aggregate boundary is incomplete or cyclic.""" 

30 

31 

32@dataclass(frozen=True) 

33class ModelCloneContract: 

34 """Copy declaration for one concrete Django model.""" 

35 

36 label: str 

37 policies: dict[CloneProfile, ClonePolicy] 

38 clear_relations: frozenset[str] = field(default_factory=frozenset) 

39 

40 def policy_for(self, profile: CloneProfile) -> ClonePolicy: 

41 """Return the action declared for ``profile``.""" 

42 

43 return self.policies.get(profile, ClonePolicy.EXCLUDE) 

44 

45 

46COPY_LABELS = { 

47 "core.Plot", 

48 "core.Series", 

49 "core_auxiliary.IndexedItem", 

50 "core_auxiliary.ControlValue", 

51 "core_auxiliary.PropertyValue", 

52 "core_auxiliary.PropertyInfo", 

53 "core_auxiliary.PropertySet", 

54 "core_auxiliary.MLModel", 

55 "core_auxiliary.RecycleData", 

56 "core_auxiliary.RecycleProperty", 

57 "core_auxiliary.Note", 

58 "core_auxiliary.Scenario", 

59 "core_auxiliary.OptimizationDegreesOfFreedom", 

60 "core_auxiliary.ParameterSweepDefinition", 

61 "core_auxiliary.ParameterSweepParameter", 

62 "core_auxiliary.DataColumn", 

63 "core_auxiliary.DataRow", 

64 "core_auxiliary.DataCell", 

65 "core_auxiliary.MLColumnMapping", 

66 "core_auxiliary.MonitoringTable", 

67 "core_auxiliary.MonitoringTableProperty", 

68 "core_auxiliary.CustomCompound", 

69 "core_auxiliary.CompoundProperty", 

70 "core_auxiliary.CustomPropertyPackage", 

71 "core_auxiliary.CustomPropertyPackageProperty", 

72 "core_auxiliary.Kappa", 

73 "flowsheetInternals_unitops.Port", 

74 "flowsheetInternals_unitops.SimulationObject", 

75 "flowsheetInternals_graphicData.PortAnchorPlacement", 

76 "flowsheetInternals_graphicData.GraphicObject", 

77 "flowsheetInternals_graphicData.Grouping", 

78 "PinchAnalysis.PinchInputs", 

79 "PinchAnalysis.StreamDataEntry", 

80 "PinchAnalysis.Segment", 

81 "PinchAnalysis.PinchUtility", 

82 "PinchAnalysis.TurbineOptions", 

83 "PinchAnalysis.MainOptions", 

84 "PinchAnalysis.StreamDataProject", 

85 "PinchAnalysis.HenNode", 

86 "Economics.EconomicsStudy", 

87 "Economics.EconomicsStudyComparisonConfig", 

88 "Economics.EconomicsStudyComparisonSelection", 

89 "Economics.EconomicsSchedulePlan", 

90 "Economics.EconomicsScheduleRule", 

91 "Economics.EconomicsAssumptions", 

92 "Economics.EconomicsBaseline", 

93 "Economics.CostableItem", 

94 "Economics.EquipmentMapping", 

95 "Economics.CostDriver", 

96 "Economics.CapitalCostLine", 

97 "Economics.OperatingCostLine", 

98 "Economics.EconomicsMetricFormula", 

99 "Economics.EconomicsLineFormula", 

100} 

101 

102EXCLUDE_LABELS = { 

103 "core_auxiliary.HistoricalValue", 

104 "core_auxiliary.ProcessPathProperty", 

105 "core_auxiliary.BuildStateRequestVersion", 

106 "core_auxiliary.ProcessPath", 

107 "core_auxiliary.UploadSession", 

108 "core_auxiliary.ScenarioResultSummaryCache", 

109 "core_auxiliary.Solution", 

110 "core_auxiliary.SolveCompletionEmail", 

111 "core_auxiliary.Task", 

112 "PinchAnalysis.PinchTemp", 

113 "PinchAnalysis.TargetSummary", 

114 "PinchAnalysis.HeatSupplierUtilitySummary", 

115 "PinchAnalysis.HeatReceiverUtilitySummary", 

116 "PinchAnalysis.PinchGraphSet", 

117 "PinchAnalysis.PinchGraph", 

118 "PinchAnalysis.PinchCurve", 

119 "PinchAnalysis.GraphDataPoint", 

120 "Economics.EconomicsResultRun", 

121 "Economics.EconomicsResultDependency", 

122 "Economics.EconomicsResultLine", 

123 "Economics.EconomicsChartDataset", 

124 "diagnostics.DiagnosticsResult", 

125} 

126 

127REBUILD_LABELS = { 

128 "core_auxiliary.ObjectTypeCounter", 

129 "PinchAnalysis.PinchOutputs", 

130} 

131 

132# Module templates historically copy the core topology/configuration portion of 

133# their source flowsheet, not Pinch or Economics state and not large scenario 

134# input tables. The separate coordinator uses this explicit subset. DataColumn 

135# and P-Graph path rows remain included here because the pre-cutover module 

136# insertion path copied them, even though whole-state revision profiles do not. 

137MODULE_COPY_LABELS = { 

138 label 

139 for label in COPY_LABELS 

140 if not label.startswith(("PinchAnalysis.", "Economics.")) 

141 and label 

142 not in { 

143 "core_auxiliary.DataRow", 

144 "core_auxiliary.DataCell", 

145 } 

146} 

147MODULE_COPY_LABELS.update( 

148 { 

149 "core_auxiliary.ProcessPath", 

150 "core_auxiliary.ProcessPathProperty", 

151 } 

152) 

153 

154STABLE_FK_CLASSIFICATION = { 

155 "core_auxiliary.flowsheetstate.flowsheet": "identity", 

156 "core_auxiliary.project.active_flowsheet": "project", 

157 "core_auxiliary.solvecompletionemail.flowsheet": "operational", 

158 "core_auxiliary.task.flowsheet": "operational", 

159 "core_auxiliary.uploadsession.flowsheet": "operational", 

160 "flowsheetInternals_unitops.flowsheeteditoperation.flowsheet": "operational", 

161} 

162 

163STATE_REFERENCE_CLASSIFICATION = { 

164 "core_auxiliary.flowsheet.current_state": "identity_pointer", 

165} 

166 

167 

168def _contract(label: str) -> ModelCloneContract: 

169 policies = { 

170 CloneProfile.REVISION: ClonePolicy.EXCLUDE, 

171 CloneProfile.RESTORE: ClonePolicy.EXCLUDE, 

172 CloneProfile.NEW_FLOWSHEET: ClonePolicy.EXCLUDE, 

173 CloneProfile.MODULE_SUBTREE: ClonePolicy.EXCLUDE, 

174 } 

175 if label in COPY_LABELS: 

176 policies.update( 

177 { 

178 CloneProfile.REVISION: ClonePolicy.COPY, 

179 CloneProfile.RESTORE: ClonePolicy.COPY, 

180 CloneProfile.NEW_FLOWSHEET: ClonePolicy.COPY, 

181 } 

182 ) 

183 elif label == "core_auxiliary.ObjectTypeCounter": 

184 policies.update( 

185 { 

186 CloneProfile.RESTORE: ClonePolicy.REBUILD, 

187 CloneProfile.NEW_FLOWSHEET: ClonePolicy.REBUILD, 

188 } 

189 ) 

190 elif label == "PinchAnalysis.PinchOutputs": 

191 policies.update( 

192 { 

193 CloneProfile.RESTORE: ClonePolicy.REBUILD, 

194 CloneProfile.NEW_FLOWSHEET: ClonePolicy.REBUILD, 

195 } 

196 ) 

197 if label in MODULE_COPY_LABELS: 

198 policies[CloneProfile.MODULE_SUBTREE] = ClonePolicy.COPY 

199 

200 clear_relations = frozenset( 

201 {"csv_upload_session"} if label == "core_auxiliary.MLModel" else () 

202 ) 

203 return ModelCloneContract( 

204 label=label, 

205 policies=policies, 

206 clear_relations=clear_relations, 

207 ) 

208 

209 

210class FlowsheetStateCloneRegistry: 

211 """Validated registry and dependency-order provider.""" 

212 

213 def __init__(self): 

214 labels = COPY_LABELS | EXCLUDE_LABELS | REBUILD_LABELS 

215 self._contracts = {label: _contract(label) for label in labels} 

216 

217 def contract_for(self, model_or_label) -> ModelCloneContract: 

218 """Resolve a contract by model class or canonical Django label.""" 

219 

220 label = ( 

221 model_or_label 

222 if isinstance(model_or_label, str) 

223 else model_or_label._meta.label 

224 ) 

225 try: 

226 return self._contracts[label] 

227 except KeyError as exc: 

228 raise CloneRegistryError(f"No clone contract for {label}.") from exc 

229 

230 def policy_for(self, model_or_label, profile: CloneProfile) -> ClonePolicy: 

231 """Return the model action for one profile.""" 

232 

233 return self.contract_for(model_or_label).policy_for(profile) 

234 

235 def copy_models( 

236 self, 

237 profile: CloneProfile, 

238 *, 

239 include_scenario_results: bool = False, 

240 ) -> list[type]: 

241 """Return included models in validated foreign-key dependency order. 

242 

243 Scenario results are the one optional model in a full duplication; 

244 revisions, restores, and ordinary duplicates keep excluding them. 

245 """ 

246 

247 included = { 

248 apps.get_model(label) 

249 for label, contract in self._contracts.items() 

250 if contract.policy_for(profile) == ClonePolicy.COPY 

251 or ( 

252 include_scenario_results 

253 and label == "core_auxiliary.Solution" 

254 ) 

255 } 

256 dependencies = {model: set() for model in included} 

257 for model in included: 

258 for relation in model._meta.concrete_fields: 

259 if ( 

260 relation.name == "flowsheet_state" 

261 or not relation.is_relation 

262 or relation.remote_field is None 

263 or relation.remote_field.model not in included 

264 ): 

265 continue 

266 dependencies[model].add(relation.remote_field.model) 

267 

268 ordered = [] 

269 remaining = dict(dependencies) 

270 while remaining: 

271 ready = sorted( 

272 (model for model, deps in remaining.items() if not deps), 

273 key=lambda model: model._meta.label_lower, 

274 ) 

275 if not ready: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 cycle = ", ".join( 

277 sorted(model._meta.label for model in remaining) 

278 ) 

279 raise CloneRegistryError( 

280 f"Clone dependency cycle requires an explicit handler: {cycle}." 

281 ) 

282 ordered.extend(ready) 

283 for model in ready: 

284 remaining.pop(model) 

285 for deps in remaining.values(): 

286 deps.difference_update(ready) 

287 return ordered 

288 

289 def validate_completeness(self) -> None: 

290 """Validate state ownership, stable ownership, and include/exclude edges.""" 

291 

292 from core.auxiliary.models.Flowsheet import Flowsheet 

293 from core.auxiliary.models.FlowsheetState import FlowsheetState 

294 

295 state_owned_models = [] 

296 stable_fields = set() 

297 state_reference_fields = set() 

298 for model in apps.get_models(): 

299 state_fields = [ 

300 relation 

301 for relation in model._meta.concrete_fields 

302 if ( 

303 relation.is_relation 

304 and relation.remote_field is not None 

305 and relation.remote_field.model is FlowsheetState 

306 ) 

307 ] 

308 owner_fields = [ 

309 relation 

310 for relation in state_fields 

311 if relation.name == "flowsheet_state" 

312 ] 

313 if owner_fields: 

314 if len(owner_fields) != 1: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true

315 raise CloneRegistryError( 

316 f"{model._meta.label} has multiple state owner fields." 

317 ) 

318 state_owned_models.append(model) 

319 self.contract_for(model) 

320 state_reference_fields.update( 

321 f"{model._meta.label_lower}.{relation.name}" 

322 for relation in state_fields 

323 if relation.name != "flowsheet_state" 

324 ) 

325 

326 for relation in model._meta.concrete_fields: 

327 if ( 

328 relation.is_relation 

329 and relation.remote_field is not None 

330 and relation.remote_field.model is Flowsheet 

331 ): 

332 stable_fields.add( 

333 f"{model._meta.label_lower}.{relation.name}" 

334 ) 

335 if stable_fields != set(STABLE_FK_CLASSIFICATION): 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true

336 missing = stable_fields - set(STABLE_FK_CLASSIFICATION) 

337 stale = set(STABLE_FK_CLASSIFICATION) - stable_fields 

338 raise CloneRegistryError( 

339 f"Stable flowsheet FK classification mismatch; missing={sorted(missing)}, " 

340 f"stale={sorted(stale)}." 

341 ) 

342 if state_reference_fields != set(STATE_REFERENCE_CLASSIFICATION): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 missing = state_reference_fields - set(STATE_REFERENCE_CLASSIFICATION) 

344 stale = set(STATE_REFERENCE_CLASSIFICATION) - state_reference_fields 

345 raise CloneRegistryError( 

346 "FlowsheetState reference classification mismatch; " 

347 f"missing={sorted(missing)}, stale={sorted(stale)}." 

348 ) 

349 

350 state_owned_set = set(state_owned_models) 

351 for profile in ( 

352 CloneProfile.REVISION, 

353 CloneProfile.RESTORE, 

354 CloneProfile.NEW_FLOWSHEET, 

355 CloneProfile.MODULE_SUBTREE, 

356 ): 

357 for model in self.copy_models(profile): 

358 contract = self.contract_for(model) 

359 for relation in model._meta.concrete_fields: 

360 if ( 

361 relation.name == "flowsheet_state" 

362 or not relation.is_relation 

363 or relation.remote_field is None 

364 or relation.remote_field.model not in state_owned_set 

365 ): 

366 continue 

367 related_policy = self.policy_for( 

368 relation.remote_field.model, 

369 profile, 

370 ) 

371 if related_policy == ClonePolicy.COPY: 

372 continue 

373 if relation.null and relation.name in contract.clear_relations: 373 ↛ 375line 373 didn't jump to line 375 because the condition on line 373 was always true

374 continue 

375 raise CloneRegistryError( 

376 f"{model._meta.label}.{relation.name} points to " 

377 f"{related_policy} {relation.remote_field.model._meta.label} " 

378 f"without a declared clear/rebuild rule for {profile}." 

379 ) 

380 

381 # Force cycle validation even when callers only run the audit. 

382 for profile in CloneProfile: 

383 self.copy_models(profile) 

384 

385 

386registry = FlowsheetStateCloneRegistry()