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
« prev ^ index » next coverage.py v7.10.7, created at 2026-06-23 21:51 +0000
1import logging
2from typing import List
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
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
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)
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()
44 @extend_schema_field(serializers.ListField(child=serializers.CharField()))
45 def get_indexedSets(self, instance: PropertyValue) -> list:
46 return instance.get_indexes()
48 @extend_schema_field(serializers.ListField(child=serializers.CharField()))
49 def get_indexedSetNames(self, instance: PropertyValue) -> list:
50 return instance.get_index_names()
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.
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", [])
62 class Meta:
63 model = PropertyValue
64 fields = "__all__"
65 read_only_fields = ['id', 'controlManipulated', 'controlSetPoint']
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
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)
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
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()
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
106 if "formula" in validated_data:
107 instance.formula = validated_data["formula"]
108 instance.save()
109 return
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)
116 object_type = simulation_object.objectType
117 config = configuration.get(object_type)
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.
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]
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())
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
146 property_set.ContainedProperties.all().prefetch_related("values")
148 revert_values: dict[PropertyValue, float] = {}
149 mole_frac_comp = property_set.get_property("mole_frac_comp")
151 def get_revert_values():
152 for prop in mole_frac_comp.values.all():
153 revert_values[prop] = prop.value
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)
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)
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)
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
233 PropertyValue.objects.bulk_update(value_objs, ["value"])
234 raise e
236 def _attach_diagnostics_findings(self, instance: PropertyValue, validated_data: dict) -> None:
237 """
238 Attach diagnostics rule findings to the instance for response serialization.
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__)
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
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
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
264 try:
265 property_info: PropertyInfo = instance.property
266 property_set: PropertySet = property_info.set
267 simulation_object = property_set.simulationObject
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 = []