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

168 statements  

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

1from core.serializer_base import StateOwnedModelSerializer 

2import logging 

3from typing import List 

4 

5from django.core.exceptions import ValidationError as DjangoValidationError 

6from diagnostics.serializers import FindingSerializer 

7from drf_spectacular.utils import extend_schema_field 

8from rest_framework import serializers 

9 

10from core.auxiliary.formula_limits import validate_formula_length 

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

12from core.auxiliary.models.PropertySet import PropertySet 

13from core.auxiliary.models.PropertyValue import PropertyValue 

14from core.auxiliary.property_state import reject_property_update 

15from flowsheetInternals.unitops.config.config_base import configuration 

16from flowsheetInternals.unitops.services.edit_operations.recorder import ( 

17 tracked_bulk_update, 

18) 

19 

20from ..viewsets.compound_conversions import ( 

21 check_fully_defined, 

22 convert_to_molar_fractions, 

23 convert_to_raw_values, 

24 stream_has_build_state_inputs, 

25) 

26 

27class PropertyValueSerializer(StateOwnedModelSerializer): 

28 controlManipulated = serializers.IntegerField( 

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

30 controlManipulatedName = serializers.CharField( 

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

32 controlManipulatedId = serializers.IntegerField( 

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

34 controlSetPoint = serializers.IntegerField( 

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

36 controlSetPointName = serializers.CharField( 

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

38 controlSetPointId = serializers.IntegerField( 

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

40 controlManipulatedObject = serializers.CharField( 

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

42 controlSetPointObject = serializers.CharField( 

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

44 indexedSets = serializers.SerializerMethodField() 

45 indexedSetNames = serializers.SerializerMethodField() 

46 diagnosticFindings = serializers.SerializerMethodField() 

47 

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

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

50 return instance.get_indexes() 

51 

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

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

54 return instance.get_index_names() 

55 

56 @extend_schema_field(FindingSerializer(many=True)) 

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

58 """ 

59 Deterministic diagnostics findings for this single property value. 

60 

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

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

63 """ 

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

65 

66 class Meta: 

67 model = PropertyValue 

68 fields = "__all__" 

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

70 

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

72 try: 

73 return validate_formula_length(value) 

74 except DjangoValidationError as exc: 

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

76 

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

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

79 if property_info is None: 

80 property_info = attrs.get("property") 

81 reject_property_update(property_info, attrs) 

82 return super().validate(attrs) 

83 

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

85 reject_property_update(instance.property, validated_data) 

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

87 # eg. pinch property set 

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

89 self.handle_update(instance, validated_data) 

90 self._attach_diagnostics_findings(instance, validated_data) 

91 return instance 

92 

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

94 value = validated_data["value"] 

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

96 instance.value = value 

97 instance.displayValue = displayValue 

98 instance.save() 

99 

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

101 from idaes_factory.endpoints import ( 

102 BuildStateSolveError, 

103 build_state_request, 

104 ) 

105 if "tag" in validated_data: 

106 instance.tag = validated_data["tag"] 

107 instance.save() 

108 return 

109 

110 if "formula" in validated_data: 

111 instance.formula = validated_data["formula"] 

112 instance.save() 

113 return 

114 

115 property_info: PropertyInfo = instance.property 

116 property_set: PropertySet = property_info.set 

117 simulation_object = property_set.simulationObject 

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

119 

120 object_type = simulation_object.objectType 

121 config = configuration.get(object_type) 

122 

123 if simulation_object.objectType != "stream": 

124 handle_save() 

125 if check_is_except_last(property_info): 

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

127 

128 # get the first index set 

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

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

131 

132 # Get the all the indices for this PropertyValue 

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

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

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

136 filtered_properties = instance.property.values 

137 for index in indexed_item_ids: 

138 filtered_properties = filtered_properties.filter( 

139 indexedItems__id=index) 

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

141 

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

143 calculated_property_value = other_property_values[-1] 

144 known_property_values = other_property_values[:-1] 

145 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) 

146 calculated_property_value.value = 1 - total_ 

147 calculated_property_value.save() 

148 return 

149 

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

151 

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

153 mole_frac_comp = property_set.get_property("mole_frac_comp") 

154 

155 def get_revert_values(): 

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

157 revert_values[prop] = prop.value 

158 

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

160 if property_info.key == "mole_frac_comp": 

161 fully_defined = check_fully_defined( 

162 property_set, check_none_empty=True) 

163 

164 if fully_defined: 

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

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

167 convert_to_raw_values(property_set) 

168 handle_save() 

169 else: 

170 handle_save() 

171 mole_frac_comp.refresh_from_db() 

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

173 # previously had a value, staying as fully defined 

174 convert_to_molar_fractions(property_set) 

175 else: 

176 convert_to_raw_values(property_set) 

177 handle_save() 

178 else: 

179 # not fully defined 

180 handle_save() 

181 mole_frac_comp.refresh_from_db() 

182 if ( 

183 not update_is_empty 

184 and check_fully_defined(property_set, [mole_frac_comp]) 

185 ): 

186 # this could make us fully defined 

187 if check_fully_defined(property_set, check_fraction_sum=True): 

188 get_revert_values() 

189 convert_to_molar_fractions(property_set) 

190 

191 else: 

192 fully_defined = check_fully_defined( 

193 property_set, check_none_empty=True) 

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

195 if fully_defined: 

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

197 convert_to_raw_values(property_set) 

198 handle_save() 

199 else: 

200 handle_save() 

201 if ( 

202 not fully_defined 

203 and check_fully_defined(property_set, check_fraction_sum=True) 

204 ): 

205 # we are now fully defined 

206 get_revert_values() 

207 convert_to_molar_fractions(property_set) 

208 

209 simulation_object.refresh_from_db() 

210 if ( 

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

212 and stream_has_build_state_inputs(property_set) 

213 and all([not value.is_externally_controlled() 

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

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

216 ): 

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

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

219 # stream property specified up front. 

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

221 try: 

222 build_state_request( 

223 simulation_object, 

224 request_user, 

225 rollback_values={ 

226 prop.id: value 

227 for prop, value in revert_values.items() 

228 } or None, 

229 ) 

230 except BuildStateSolveError as e: 

231 # revert values if any 

232 value_objs = [] 

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

234 value_objs.append(prop) 

235 prop.value = value 

236 

237 tracked_bulk_update(PropertyValue.objects, value_objs, ["value"]) 

238 raise e 

239 

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

241 """ 

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

243 

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

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

246 """ 

247 logger = logging.getLogger(__name__) 

248 

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

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

251 instance._diagnostic_findings = [] 

252 return 

253 

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

255 try: 

256 numeric_value = float(raw_value) 

257 except (TypeError, ValueError): 

258 instance._diagnostic_findings = [] 

259 return 

260 

261 try: 

262 from diagnostics.rules.engine import build_rule_context, evaluate_rules 

263 except Exception: 

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

265 instance._diagnostic_findings = [] 

266 return 

267 

268 try: 

269 property_info: PropertyInfo = instance.property 

270 property_set: PropertySet = property_info.set 

271 simulation_object = property_set.simulationObject 

272 

273 ctx = build_rule_context(simulation_object, property_info, numeric_value) 

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

275 except Exception: 

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

277 logger.error( 

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

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

280 raw_value, 

281 exc_info=True, 

282 ) 

283 instance._diagnostic_findings = []