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
« 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
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
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)
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)
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()
48 @extend_schema_field(serializers.ListField(child=serializers.CharField()))
49 def get_indexedSets(self, instance: PropertyValue) -> list:
50 return instance.get_indexes()
52 @extend_schema_field(serializers.ListField(child=serializers.CharField()))
53 def get_indexedSetNames(self, instance: PropertyValue) -> list:
54 return instance.get_index_names()
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.
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", [])
66 class Meta:
67 model = PropertyValue
68 fields = "__all__"
69 read_only_fields = ['id', 'controlManipulated', 'controlSetPoint']
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
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)
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
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()
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
110 if "formula" in validated_data:
111 instance.formula = validated_data["formula"]
112 instance.save()
113 return
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)
120 object_type = simulation_object.objectType
121 config = configuration.get(object_type)
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.
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]
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())
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
150 property_set.ContainedProperties.all().prefetch_related("values")
152 revert_values: dict[PropertyValue, float] = {}
153 mole_frac_comp = property_set.get_property("mole_frac_comp")
155 def get_revert_values():
156 for prop in mole_frac_comp.values.all():
157 revert_values[prop] = prop.value
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)
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)
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)
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
237 tracked_bulk_update(PropertyValue.objects, value_objs, ["value"])
238 raise e
240 def _attach_diagnostics_findings(self, instance: PropertyValue, validated_data: dict) -> None:
241 """
242 Attach diagnostics rule findings to the instance for response serialization.
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__)
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
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
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
268 try:
269 property_info: PropertyInfo = instance.property
270 property_set: PropertySet = property_info.set
271 simulation_object = property_set.simulationObject
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 = []