Coverage for backend/django/core/auxiliary/serializers/PropertyValueSerializer.py: 86%

166 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-06-23 21:51 +0000

1import logging 

2from typing import List 

3 

4from django.core.exceptions import ValidationError as DjangoValidationError 

5from diagnostics.serializers import FindingSerializer 

6from drf_spectacular.utils import extend_schema_field 

7from rest_framework import serializers 

8 

9from core.auxiliary.formula_limits import validate_formula_length 

10from core.auxiliary.models.PropertyInfo import PropertyInfo, check_is_except_last 

11from core.auxiliary.models.PropertySet import PropertySet 

12from core.auxiliary.models.PropertyValue import PropertyValue 

13from core.auxiliary.property_state import reject_property_update 

14from flowsheetInternals.unitops.config.config_base import configuration 

15 

16from ..viewsets.compound_conversions import ( 

17 check_fully_defined, 

18 convert_to_molar_fractions, 

19 convert_to_raw_values, 

20 stream_has_build_state_inputs, 

21) 

22 

23class PropertyValueSerializer(serializers.ModelSerializer): 

24 controlManipulated = serializers.IntegerField( 

25 source='controlManipulated.id', read_only=True) 

26 controlManipulatedName = serializers.CharField( 

27 source='controlManipulated.setPoint.property.displayName', read_only=True) 

28 controlManipulatedId = serializers.IntegerField( 

29 source='controlManipulated.setPoint.property.set.simulationObject.id', read_only=True) 

30 controlSetPoint = serializers.IntegerField( 

31 source='controlSetPoint.id', read_only=True) 

32 controlSetPointName = serializers.CharField( 

33 source='controlSetPoint.manipulated.property.displayName', read_only=True) 

34 controlSetPointId = serializers.IntegerField( 

35 source='controlSetPoint.manipulated.property.set.simulationObject.id', read_only=True) 

36 controlManipulatedObject = serializers.CharField( 

37 source='controlManipulated.setPoint.property.set.simulationObject.componentName', read_only=True) 

38 controlSetPointObject = serializers.CharField( 

39 source='controlSetPoint.manipulated.property.set.simulationObject.componentName', read_only=True) 

40 indexedSets = serializers.SerializerMethodField() 

41 indexedSetNames = serializers.SerializerMethodField() 

42 diagnosticFindings = serializers.SerializerMethodField() 

43 

44 @extend_schema_field(serializers.ListField(child=serializers.CharField())) 

45 def get_indexedSets(self, instance: PropertyValue) -> list: 

46 return instance.get_indexes() 

47 

48 @extend_schema_field(serializers.ListField(child=serializers.CharField())) 

49 def get_indexedSetNames(self, instance: PropertyValue) -> list: 

50 return instance.get_index_names() 

51 

52 @extend_schema_field(FindingSerializer(many=True)) 

53 def get_diagnosticFindings(self, instance: PropertyValue) -> list: 

54 """ 

55 Deterministic diagnostics findings for this single property value. 

56 

57 These findings are attached during updates so the frontend can show rule 

58 feedback without making an extra /api/diagnostics/... request. 

59 """ 

60 return getattr(instance, "_diagnostic_findings", []) 

61 

62 class Meta: 

63 model = PropertyValue 

64 fields = "__all__" 

65 read_only_fields = ['id', 'controlManipulated', 'controlSetPoint'] 

66 

67 def validate_formula(self, value: str | None) -> str | None: 

68 try: 

69 return validate_formula_length(value) 

70 except DjangoValidationError as exc: 

71 raise serializers.ValidationError(exc.messages) from exc 

72 

73 def validate(self, attrs: dict) -> dict: 

74 property_info = getattr(self.instance, "property", None) 

75 if property_info is None: 

76 property_info = attrs.get("property") 

77 reject_property_update(property_info, attrs) 

78 return super().validate(attrs) 

79 

80 def update(self, instance: PropertyValue, validated_data: dict) -> None: 

81 reject_property_update(instance.property, validated_data) 

82 if not instance.property.set.has_simulation_object: 82 ↛ 84line 82 didn't jump to line 84 because the condition on line 82 was never true

83 # eg. pinch property set 

84 return super().update(instance, validated_data) 

85 self.handle_update(instance, validated_data) 

86 self._attach_diagnostics_findings(instance, validated_data) 

87 return instance 

88 

89 def handle_save(self, instance, validated_data) -> None: 

90 value = validated_data["value"] 

91 displayValue = validated_data.get("displayValue", value) 

92 instance.value = value 

93 instance.displayValue = displayValue 

94 instance.save() 

95 

96 def handle_update(self, instance, validated_data) -> None: 

97 from idaes_factory.endpoints import ( 

98 BuildStateSolveError, 

99 build_state_request, 

100 ) 

101 if "tag" in validated_data: 

102 instance.tag = validated_data["tag"] 

103 instance.save() 

104 return 

105 

106 if "formula" in validated_data: 

107 instance.formula = validated_data["formula"] 

108 instance.save() 

109 return 

110 

111 property_info: PropertyInfo = instance.property 

112 property_set: PropertySet = property_info.set 

113 simulation_object = property_set.simulationObject 

114 def handle_save(): return self.handle_save(instance, validated_data) 

115 

116 object_type = simulation_object.objectType 

117 config = configuration.get(object_type) 

118 

119 if simulation_object.objectType != "stream": 

120 handle_save() 

121 if check_is_except_last(property_info): 

122 # The last property should be calculated so that all of the last index set sums to 1. 

123 

124 # get the first index set 

125 # The last items in the first index set will be calculated. 

126 first_index_type = property_info.get_schema().indexSets[0] 

127 

128 # Get the all the indices for this PropertyValue 

129 indexed_items = list(instance.indexedItems.all()) 

130 indexed_item_ids = [index.id for index in indexed_items if index.type != first_index_type] 

131 # Get all the other property values that have the same indices 

132 filtered_properties = instance.property.values 

133 for index in indexed_item_ids: 

134 filtered_properties = filtered_properties.filter( 

135 indexedItems__id=index) 

136 other_property_values: List[PropertyValue] = list(filtered_properties.all()) 

137 

138 # Split off the last one, that will be calculated 

139 calculated_property_value = other_property_values[-1] 

140 known_property_values = other_property_values[:-1] 

141 total_ = sum([float(prop_val.value) for prop_val in known_property_values if prop_val.pk != instance.pk and prop_val.value != None]) + float(instance.value) 

142 calculated_property_value.value = 1 - total_ 

143 calculated_property_value.save() 

144 return 

145 

146 property_set.ContainedProperties.all().prefetch_related("values") 

147 

148 revert_values: dict[PropertyValue, float] = {} 

149 mole_frac_comp = property_set.get_property("mole_frac_comp") 

150 

151 def get_revert_values(): 

152 for prop in mole_frac_comp.values.all(): 

153 revert_values[prop] = prop.value 

154 

155 update_is_empty = validated_data["value"] in [None, ""] 

156 if property_info.key == "mole_frac_comp": 

157 fully_defined = check_fully_defined( 

158 property_set, check_none_empty=True) 

159 

160 if fully_defined: 

161 if update_is_empty: 161 ↛ 163line 161 didn't jump to line 163 because the condition on line 161 was never true

162 # we are becoming undefined, set all the properties to "raw" values 

163 convert_to_raw_values(property_set) 

164 handle_save() 

165 else: 

166 handle_save() 

167 mole_frac_comp.refresh_from_db() 

168 if check_fully_defined(property_set, check_fraction_sum=True): 168 ↛ 172line 168 didn't jump to line 172 because the condition on line 168 was always true

169 # previously had a value, staying as fully defined 

170 convert_to_molar_fractions(property_set) 

171 else: 

172 convert_to_raw_values(property_set) 

173 handle_save() 

174 else: 

175 # not fully defined 

176 handle_save() 

177 mole_frac_comp.refresh_from_db() 

178 if ( 

179 not update_is_empty 

180 and check_fully_defined(property_set, [mole_frac_comp]) 

181 ): 

182 # this could make us fully defined 

183 if check_fully_defined(property_set, check_fraction_sum=True): 

184 get_revert_values() 

185 convert_to_molar_fractions(property_set) 

186 

187 else: 

188 fully_defined = check_fully_defined( 

189 property_set, check_none_empty=True) 

190 if update_is_empty: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 if fully_defined: 

192 # we are becoming undefined, set all the properties to "raw" values 

193 convert_to_raw_values(property_set) 

194 handle_save() 

195 else: 

196 handle_save() 

197 if ( 

198 not fully_defined 

199 and check_fully_defined(property_set, check_fraction_sum=True) 

200 ): 

201 # we are now fully defined 

202 get_revert_values() 

203 convert_to_molar_fractions(property_set) 

204 

205 simulation_object.refresh_from_db() 

206 if ( 

207 not property_set.disable_all # ie. outlet stream or recycle stream 

208 and stream_has_build_state_inputs(property_set) 

209 and all([not value.is_externally_controlled() 

210 for prop in PropertyInfo.objects.filter(set__simulationObject=simulation_object) 

211 for value in prop.values.all()]) 

212 ): 

213 # Streams can be built once composition is complete and one valid 

214 # state-variable pair is present; they do not need every enabled 

215 # stream property specified up front. 

216 request_user = self.context["request"].user 

217 try: 

218 build_state_request( 

219 simulation_object, 

220 request_user, 

221 rollback_values={ 

222 prop.id: value 

223 for prop, value in revert_values.items() 

224 } or None, 

225 ) 

226 except BuildStateSolveError as e: 

227 # revert values if any 

228 value_objs = [] 

229 for prop, value in revert_values.items(): 

230 value_objs.append(prop) 

231 prop.value = value 

232 

233 PropertyValue.objects.bulk_update(value_objs, ["value"]) 

234 raise e 

235 

236 def _attach_diagnostics_findings(self, instance: PropertyValue, validated_data: dict) -> None: 

237 """ 

238 Attach diagnostics rule findings to the instance for response serialization. 

239 

240 This lets the frontend get rule results in the same request that persists 

241 the updated property value (avoids a second request right after blur). 

242 """ 

243 logger = logging.getLogger(__name__) 

244 

245 # Formula-only updates don't have a meaningful numeric value to validate. 

246 if "formula" in validated_data and "value" not in validated_data: 

247 instance._diagnostic_findings = [] 

248 return 

249 

250 raw_value = getattr(instance, "value", None) 

251 try: 

252 numeric_value = float(raw_value) 

253 except (TypeError, ValueError): 

254 instance._diagnostic_findings = [] 

255 return 

256 

257 try: 

258 from diagnostics.rules.engine import build_rule_context, evaluate_rules 

259 except Exception: 

260 logger.error("Failed to import diagnostics rules engine", exc_info=True) 

261 instance._diagnostic_findings = [] 

262 return 

263 

264 try: 

265 property_info: PropertyInfo = instance.property 

266 property_set: PropertySet = property_info.set 

267 simulation_object = property_set.simulationObject 

268 

269 ctx = build_rule_context(simulation_object, property_info, numeric_value) 

270 instance._diagnostic_findings = [f.to_dict() for f in evaluate_rules(ctx)] 

271 except Exception: 

272 # Diagnostics should never fail the primary update path, but we log for debugging. 

273 logger.error( 

274 "Diagnostics rule evaluation failed for property %s (value=%s)", 

275 getattr(instance, "id", "?"), 

276 raw_value, 

277 exc_info=True, 

278 ) 

279 instance._diagnostic_findings = [] 

280