Coverage for backend/django/core/auxiliary/services/ml_column_mapping_updates.py: 88%

142 statements  

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

1from typing import Any, TypedDict 

2 

3from django.db import transaction 

4from rest_framework.exceptions import ValidationError 

5 

6from common.config_types import PropertyType as PropertyTypeObj 

7from core.auxiliary.enums.generalEnums import PropertyType 

8from core.auxiliary.models.Flowsheet import Flowsheet 

9from core.auxiliary.models.MLColumnMapping import ( 

10 MLColumnMapping, 

11 custom_property_port_index, 

12) 

13from core.auxiliary.models.MLModel import MLModel 

14from core.auxiliary.models.PropertyInfo import PropertyInfo 

15from core.auxiliary.models.PropertyValue import PropertyValue 

16from flowsheetInternals.unitops.config.config_methods import get_property_fields 

17 

18 

19class ColumnMapping(TypedDict): 

20 portIndex: int 

21 propertyKey: str 

22 column: str 

23 

24 

25PROPERTY_INFO_SNAPSHOT_FIELDS = [ 

26 "set_id", 

27 "type", 

28 "unitType", 

29 "unit", 

30 "key", 

31 "displayName", 

32 "index", 

33 "managed", 

34 "managed_source", 

35 "can_edit", 

36 "can_edit_formula", 

37 "can_delete", 

38 "formula_incomplete", 

39 "formula_incomplete_reason", 

40] 

41 

42 

43def _snapshot_mapping(mapping: MLColumnMapping) -> dict[str, Any]: 

44 property_info = mapping.propertyInfo 

45 property_value = property_info.values.first() if property_info else None 

46 property_info_snapshot = None 

47 

48 if mapping.portIndex == custom_property_port_index and property_info: 

49 property_info_snapshot = { 

50 field: getattr(property_info, field) 

51 for field in PROPERTY_INFO_SNAPSHOT_FIELDS 

52 } 

53 property_info_snapshot["value"] = property_value.value if property_value else None 

54 property_info_snapshot["displayValue"] = ( 

55 property_value.displayValue if property_value else None 

56 ) 

57 property_info_snapshot["enabled"] = property_value.enabled if property_value else True 

58 

59 return { 

60 "order": mapping.order, 

61 "column": mapping.column, 

62 "portIndex": mapping.portIndex, 

63 "propertyKey": mapping.propertyKey, 

64 "propertyType": mapping.propertyType, 

65 "propertyInfo": property_info_snapshot, 

66 "propertyInfoId": None 

67 if mapping.portIndex == custom_property_port_index 

68 else mapping.propertyInfo_id, 

69 } 

70 

71 

72def build_mapping_update_snapshot(ml_model: MLModel) -> dict[str, Any]: 

73 """Backward-compatible wrapper for the generalized ML model snapshot.""" 

74 return build_ml_model_update_snapshot(ml_model) 

75 

76 

77def build_ml_model_update_snapshot(ml_model: MLModel) -> dict[str, Any]: 

78 """Capture the current CSV-backed ML model state before a reset/retrain.""" 

79 mappings = ( 

80 ml_model.MLColumnMappings.select_related("propertyInfo") 

81 .prefetch_related("propertyInfo__values") 

82 .order_by("order", "id") 

83 ) 

84 return { 

85 "mappings": [_snapshot_mapping(mapping) for mapping in mappings], 

86 "surrogate_model": ml_model.surrogate_model, 

87 "charts": ml_model.charts, 

88 "metrics": ml_model.metrics, 

89 "result_state": ml_model.result_state, 

90 "test_results_bucket": ml_model.test_results_bucket, 

91 "test_results_key": ml_model.test_results_key, 

92 "csv_file_name": ml_model.csv_file_name, 

93 "csv_bucket": ml_model.csv_bucket, 

94 "csv_object_key": ml_model.csv_object_key, 

95 "csv_headers": ml_model.csv_headers, 

96 "csv_delimiter": ml_model.csv_delimiter, 

97 "csv_upload_session_id": ml_model.csv_upload_session_id, 

98 "active_step": ml_model.active_step, 

99 "return_step": ml_model.return_step, 

100 "completed_steps": ml_model.completed_steps or [], 

101 "is_resetting": ml_model.is_resetting, 

102 "is_updating": ml_model.is_updating, 

103 } 

104 

105 

106def _ml_model_has_restorable_state(ml_model: MLModel) -> bool: 

107 """Return whether the model has meaningful previous state to roll back to.""" 

108 return bool( 

109 ml_model.MLColumnMappings.exists() 

110 or ml_model.csv_bucket 

111 or ml_model.csv_object_key 

112 or ml_model.csv_upload_session_id 

113 or ml_model.csv_headers 

114 or ml_model.surrogate_model 

115 or ml_model.charts 

116 or ml_model.metrics 

117 or ml_model.test_results_bucket 

118 or ml_model.test_results_key 

119 ) 

120 

121 

122def stage_ml_model_update_snapshot(ml_model: MLModel) -> bool: 

123 """Stage a rollback snapshot on the model if one is not already pending.""" 

124 if ml_model.mapping_update_snapshot or not _ml_model_has_restorable_state(ml_model): 

125 return False 

126 

127 ml_model.mapping_update_snapshot = build_ml_model_update_snapshot(ml_model) 

128 return True 

129 

130 

131def _mapping_owned_custom_property_ids(ml_model: MLModel) -> list[int]: 

132 return list( 

133 ml_model.MLColumnMappings.filter( 

134 portIndex=custom_property_port_index, 

135 propertyInfo__isnull=False, 

136 ).values_list("propertyInfo_id", flat=True) 

137 ) 

138 

139 

140def _delete_mapping_owned_custom_properties(property_info_ids: list[int]) -> None: 

141 if property_info_ids: 

142 PropertyInfo.objects.filter(id__in=property_info_ids).delete() 

143 

144 

145def clear_ml_model_column_mappings(ml_model: MLModel) -> None: 

146 """Remove an ML model's mappings and the custom properties they own.""" 

147 mapping_owned_custom_property_ids = _mapping_owned_custom_property_ids(ml_model) 

148 ml_model.MLColumnMappings.all().delete() 

149 _delete_mapping_owned_custom_properties(mapping_owned_custom_property_ids) 

150 

151 

152def _create_mapping_objects( 

153 *, 

154 flowsheet: Flowsheet, 

155 ml_model: MLModel, 

156 inlet_mappings: list[ColumnMapping], 

157 outlet_mappings: list[ColumnMapping], 

158) -> None: 

159 if not ml_model.simulationObject: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true

160 raise ValidationError({"model": "ML model must be attached to a simulation object."}) 

161 if ml_model.flowsheet_state.flowsheet_id != flowsheet.id: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 raise ValidationError({"model": "ML model does not belong to the requested flowsheet."}) 

163 

164 flowsheet_state = ml_model.flowsheet_state 

165 property_set = ml_model.simulationObject.properties 

166 bulk_create_objects = [] 

167 mappings = [ 

168 (inlet_mappings, PropertyType.InletProperty), 

169 (outlet_mappings, PropertyType.OutletProperty), 

170 ] 

171 header_order_lookup = { 

172 header: index for index, header in enumerate(ml_model.csv_headers or []) 

173 } 

174 next_fallback_order = len(header_order_lookup) 

175 

176 for mappings_list, property_type in mappings: 

177 for mapping in mappings_list: 

178 property_key = mapping.get("propertyKey") 

179 port_index = mapping.get("portIndex") 

180 column = mapping.get("column") 

181 property_info = None 

182 

183 if property_key is None or port_index is None or column is None: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 raise ValidationError( 

185 {"mappings": "Each mapping requires propertyKey, portIndex, and column."} 

186 ) 

187 

188 if port_index == custom_property_port_index: 

189 prop = PropertyTypeObj( 

190 displayName=property_key, 

191 value="", 

192 unitType="ratio", 

193 type="numeric", 

194 ) 

195 fields = get_property_fields(property_key, prop, property_set, 0) 

196 value = fields.pop("value") 

197 property_info = PropertyInfo.objects.create( 

198 **fields, 

199 flowsheet_state=flowsheet_state, 

200 ) 

201 PropertyValue.objects.create( 

202 value=value, 

203 property=property_info, 

204 enabled=property_type == PropertyType.InletProperty, 

205 flowsheet_state=flowsheet_state, 

206 ) 

207 

208 order = header_order_lookup.get(column) 

209 if order is None: 

210 order = next_fallback_order 

211 next_fallback_order += 1 

212 

213 bulk_create_objects.append( 

214 MLColumnMapping( 

215 order=order, 

216 model=ml_model, 

217 column=column, 

218 portIndex=port_index, 

219 propertyKey=property_key, 

220 propertyType=property_type, 

221 propertyInfo=property_info, 

222 flowsheet_state=flowsheet_state, 

223 ) 

224 ) 

225 

226 MLColumnMapping.objects.bulk_create(bulk_create_objects) 

227 

228 

229def create_column_mapping( 

230 flowsheet: Flowsheet, 

231 model: int, 

232 inlet_mappings: list[ColumnMapping], 

233 outlet_mappings: list[ColumnMapping], 

234) -> None: 

235 """Create first-time column mappings for an ML model.""" 

236 ml_model = MLModel.objects.select_related( 

237 "simulationObject", 

238 "flowsheet_state__flowsheet", 

239 ).get(id=model) 

240 _create_mapping_objects( 

241 flowsheet=flowsheet, 

242 ml_model=ml_model, 

243 inlet_mappings=inlet_mappings, 

244 outlet_mappings=outlet_mappings, 

245 ) 

246 ml_model.return_step = ml_model.active_step 

247 ml_model.active_step = 2 

248 if ( 

249 ml_model.surrogate_model 

250 and not ml_model.csv_bucket 

251 and not ml_model.csv_object_key 

252 ): 

253 ml_model.result_state = MLModel.ResultState.READY_WITHOUT_DIAGNOSTICS 

254 else: 

255 ml_model.result_state = MLModel.ResultState.PENDING 

256 ml_model.save(update_fields=["active_step", "return_step", "result_state"]) 

257 

258 

259@transaction.atomic 

260def replace_column_mapping( 

261 *, 

262 flowsheet: Flowsheet, 

263 model: int, 

264 inlet_mappings: list[ColumnMapping], 

265 outlet_mappings: list[ColumnMapping], 

266) -> None: 

267 """Replace existing mappings and stage a rollback snapshot for retraining.""" 

268 ml_model = MLModel.objects.select_for_update().get(id=model) 

269 

270 if not ml_model.csv_bucket or not ml_model.csv_object_key: 

271 raise ValidationError({"model": "Only CSV-trained ML models can update mappings."}) 

272 

273 if ml_model.mapping_update_snapshot: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 raise ValidationError({"model": "A mapping update is already pending for this model."}) 

275 

276 if not ml_model.MLColumnMappings.exists(): 

277 raise ValidationError({"model": "Column mappings must exist before they can be updated."}) 

278 

279 ml_model.mapping_update_snapshot = build_ml_model_update_snapshot(ml_model) 

280 clear_ml_model_column_mappings(ml_model) 

281 _create_mapping_objects( 

282 flowsheet=flowsheet, 

283 ml_model=ml_model, 

284 inlet_mappings=inlet_mappings, 

285 outlet_mappings=outlet_mappings, 

286 ) 

287 

288 ml_model.surrogate_model = {} 

289 ml_model.charts = [] 

290 ml_model.metrics = [] 

291 ml_model.result_state = MLModel.ResultState.PENDING 

292 ml_model.test_results_bucket = "" 

293 ml_model.test_results_key = "" 

294 ml_model.return_step = ml_model.active_step 

295 ml_model.active_step = 2 

296 ml_model.completed_steps = [0, 1, 2] 

297 ml_model.save( 

298 update_fields=[ 

299 "mapping_update_snapshot", 

300 "surrogate_model", 

301 "charts", 

302 "metrics", 

303 "result_state", 

304 "test_results_bucket", 

305 "test_results_key", 

306 "active_step", 

307 "return_step", 

308 "completed_steps", 

309 ] 

310 ) 

311 

312 

313def _restore_mapping_from_snapshot( 

314 *, 

315 ml_model: MLModel, 

316 mapping: dict[str, Any], 

317) -> None: 

318 property_info = None 

319 property_info_snapshot = mapping.get("propertyInfo") 

320 

321 if mapping["portIndex"] == custom_property_port_index and property_info_snapshot: 

322 fields = { 

323 field: property_info_snapshot[field] 

324 for field in PROPERTY_INFO_SNAPSHOT_FIELDS 

325 if field in property_info_snapshot 

326 } 

327 # Snapshots created before property-set ownership was persisted do not 

328 # contain set_id. ML custom properties belong to the model's property 

329 # set, so retain that ownership when restoring those older snapshots. 

330 fields["set_id"] = ( 

331 property_info_snapshot.get("set_id") 

332 or ml_model.simulationObject.properties_id 

333 ) 

334 property_info = PropertyInfo.objects.create( 

335 **fields, 

336 flowsheet_state=ml_model.flowsheet_state, 

337 ) 

338 PropertyValue.objects.create( 

339 value=property_info_snapshot.get("value"), 

340 displayValue=property_info_snapshot.get("displayValue"), 

341 enabled=property_info_snapshot.get("enabled", True), 

342 property=property_info, 

343 flowsheet_state=ml_model.flowsheet_state, 

344 ) 

345 elif mapping.get("propertyInfoId"): 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 property_info = PropertyInfo.objects.filter(id=mapping["propertyInfoId"]).first() 

347 

348 MLColumnMapping.objects.create( 

349 order=mapping["order"], 

350 model=ml_model, 

351 column=mapping["column"], 

352 portIndex=mapping["portIndex"], 

353 propertyKey=mapping["propertyKey"], 

354 propertyType=mapping["propertyType"], 

355 propertyInfo=property_info, 

356 flowsheet_state=ml_model.flowsheet_state, 

357 ) 

358 

359 

360@transaction.atomic 

361def restore_ml_model_update_snapshot(ml_model: MLModel) -> bool: 

362 """Restore a failed reset/retrain if a rollback snapshot is present.""" 

363 ml_model = MLModel.objects.select_for_update().get(id=ml_model.id) 

364 snapshot = ml_model.mapping_update_snapshot 

365 if not snapshot: 

366 return False 

367 

368 clear_ml_model_column_mappings(ml_model) 

369 for mapping in snapshot.get("mappings", []): 

370 _restore_mapping_from_snapshot( 

371 ml_model=ml_model, 

372 mapping=mapping, 

373 ) 

374 

375 snapshot_result_state = snapshot.get("result_state") 

376 if snapshot_result_state not in MLModel.ResultState.values: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 if snapshot.get("charts") or snapshot.get("metrics"): 

378 snapshot_result_state = MLModel.ResultState.READY 

379 elif ( 

380 snapshot.get("surrogate_model") 

381 and not snapshot.get("csv_bucket") 

382 and not snapshot.get("csv_object_key") 

383 ): 

384 snapshot_result_state = MLModel.ResultState.READY_WITHOUT_DIAGNOSTICS 

385 else: 

386 snapshot_result_state = MLModel.ResultState.PENDING 

387 

388 MLModel.objects.filter(id=ml_model.id).update( 

389 mapping_update_snapshot=None, 

390 surrogate_model=snapshot["surrogate_model"], 

391 charts=snapshot["charts"], 

392 metrics=snapshot["metrics"], 

393 result_state=snapshot_result_state, 

394 test_results_bucket=snapshot["test_results_bucket"], 

395 test_results_key=snapshot["test_results_key"], 

396 csv_file_name=snapshot.get("csv_file_name", ml_model.csv_file_name), 

397 csv_bucket=snapshot.get("csv_bucket", ml_model.csv_bucket), 

398 csv_object_key=snapshot.get("csv_object_key", ml_model.csv_object_key), 

399 csv_headers=snapshot.get("csv_headers", ml_model.csv_headers), 

400 csv_delimiter=snapshot.get("csv_delimiter", ml_model.csv_delimiter), 

401 csv_upload_session_id=snapshot.get( 

402 "csv_upload_session_id", 

403 ml_model.csv_upload_session_id, 

404 ), 

405 active_step=snapshot["active_step"], 

406 return_step=snapshot["return_step"], 

407 completed_steps=snapshot["completed_steps"], 

408 is_resetting=snapshot.get("is_resetting", False), 

409 is_updating=snapshot.get("is_updating", False), 

410 ) 

411 return True 

412 

413 

414def restore_mapping_update_snapshot(ml_model: MLModel) -> bool: 

415 """Backward-compatible wrapper for restoring the full ML model snapshot.""" 

416 return restore_ml_model_update_snapshot(ml_model) 

417 

418 

419def clear_mapping_update_snapshot(model_id: int) -> None: 

420 MLModel.objects.filter(id=model_id).update(mapping_update_snapshot=None)