Coverage for backend/django/core/auxiliary/viewsets/compound_conversions.py: 72%
142 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 ahuora_compounds import CompoundDB
2from ..models.PropertySet import PropertySet
3from ..models.PropertyInfo import PropertyInfo
4from ..models.PropertyValue import PropertyValue
5from flowsheetInternals.unitops.services.edit_operations.recorder import (
6 tracked_bulk_update,
7)
10property_combinations = (
11 ("temperature", "pressure"),
12 ("temperature", "vapor_frac"),
13 ("pressure", "vapor_frac"),
14 ("enth_mol", "pressure"),
15 ("enth_mass", "pressure"),
16 ("entr_mol", "temperature"),
17 ("entr_mass", "temperature"),
18 ("total_energy_flow", "pressure"),
19 ("temperature", "enth_mol"),
20 ("temperature", "enth_mass"),
21 ("pressure", "entr_mol"),
22 ("pressure", "entr_mass"),
23)
26def get_molecular_weight(
27 name: str,
28 property_package: str | None = None,
29 package_compounds: tuple[str, ...] | None = None,
30) -> float:
31 """Return g/mol using the existing ChemSep behavior with a package fallback.
33 Most packages use ChemSep compounds. Package metadata is only consulted for
34 pseudo-components, such as milk solids or seawater TDS, which ChemSep does
35 not contain. Keeping the fallback here limits it to mass-basis conversion
36 instead of changing global compound-registry behavior.
37 """
38 compound = CompoundDB.get_compound(name)
39 if compound is not None and compound.MolecularWeight is not None:
40 return compound.MolecularWeight.value
42 if property_package is not None and package_compounds: 42 ↛ 53line 42 didn't jump to line 53 because the condition on line 42 was always true
43 from ahuora_property_packages.component_metadata import (
44 get_component_molecular_weight,
45 )
47 return get_component_molecular_weight(
48 property_package,
49 package_compounds,
50 name,
51 )
53 package_context = (
54 f" for property package {property_package!r}"
55 if property_package is not None
56 else ""
57 )
58 raise ValueError(
59 f"No molecular weight is available for compound {name!r}{package_context}."
60 )
63def compound_db_to_molar_flow(
64 name: str,
65 value: float,
66 property_package: str | None = None,
67 package_compounds: tuple[str, ...] | None = None,
68) -> float:
69 """
70 convert a value as mass flow to molar flow
71 (assumes converting kg/s to mol/s)
72 """
73 molecular_weight = get_molecular_weight(
74 name,
75 property_package,
76 package_compounds,
77 )
78 return value / molecular_weight * 1000
81def compound_db_to_mass_flow(
82 name: str,
83 value: float,
84 property_package: str | None = None,
85 package_compounds: tuple[str, ...] | None = None,
86) -> float:
87 """
88 convert a value as molar flow to mass flow
89 (assumes converting mol/s to kg/s)
90 """
91 molecular_weight = get_molecular_weight(
92 name,
93 property_package,
94 package_compounds,
95 )
96 return value * molecular_weight / 1000
99def get_property_set_compounds(property_set: PropertySet) -> tuple[str, ...]:
100 """Return the selected package's actual stream component names."""
101 mole_frac_comp = property_set.get_property("mole_frac_comp")
102 return tuple(
103 prop.get_index("compound").key
104 for prop in mole_frac_comp.values.all()
105 )
108def update_fraction_display_values(property_set: PropertySet) -> None:
109 """
110 Updates the display values of mass fractions based on the raw values
111 """
112 # Get the molar fraction property and its values
113 mole_frac_comp = property_set.get_property("mole_frac_comp")
114 property_values = mole_frac_comp.values.all()
115 package_compounds = get_property_set_compounds(property_set)
117 # Calculate mass for each compound and track total mass
118 compound_masses = []
119 total_mass = 0.0
121 for prop in property_values:
122 # Get molar fraction (raw value)
123 molar_fraction = float(prop.value) if prop.value not in [None, ""] else 0.0
125 # Get molecular weight and calculate mass
126 compound_name = prop.get_index("compound").key
127 molecular_weight = get_molecular_weight(
128 compound_name,
129 property_set.simulationObject.propertyPackageType,
130 package_compounds,
131 )
133 # Calculate mass (molar fraction * molecular weight)
134 mass = molar_fraction * molecular_weight
135 compound_masses.append(mass)
136 total_mass += mass
138 # Calculate and set mass fractions as display values
139 if total_mass > 0:
140 for i, prop in enumerate(property_values):
141 # Calculate mass fraction
142 mass_fraction = compound_masses[i] / total_mass
143 # Set display value
144 prop.displayValue = str(mass_fraction)
146 # Update in database
147 tracked_bulk_update(PropertyValue.objects, property_values, ["displayValue"])
150def check_fully_defined(
151 property_set: PropertySet,
152 property_infos: list[PropertyInfo] | None = None,
153 exclude: PropertyInfo | None = None,
154 check_none_empty = False,
155 check_fraction_sum = False
156 ) -> bool:
157 """
158 Returns true if the properties in the given property sets are
159 fully defined (no read-write properties).
160 """
161 if property_infos is None:
162 property_infos = property_set.containedProperties.all()
164 for prop in property_infos:
165 if (
166 check_none_empty
167 and prop.key in ["flow_mol", "flow_mass", "flow_vol"]
168 and not prop.has_value()
169 ):
170 return False
171 if exclude is not None and prop.id == exclude.id: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 continue
173 if any([
174 property_value.is_enabled() and
175 not property_value.has_value()
176 for property_value in prop.values.all()
177 ]):
178 return False
179 if (
180 check_fraction_sum
181 and property_set.compoundMode in ["MolarFraction", "MassFraction"]
182 ):
183 mole_frac_comp = property_set.get_property("mole_frac_comp")
184 return abs(
185 sum([
186 (
187 float(prop.displayValue)
188 if prop.displayValue not in [None, ""]
189 else float(prop.value)
190 if prop.value not in [None, ""]
191 else 0
192 )
193 for prop in mole_frac_comp.values.all()
194 ]) - 1
195 ) <= 1e-3 # sum of fractions == 1
197 return True
200def serialize_to_current_mode(property_set: PropertySet, properties_schema: dict) -> None:
201 """
202 Serialize the composition property set to the current compound mode
203 (for GET requests). Adjusts the properties_schema in place
204 """
205 def apply_display_value(property_key: str) -> None:
206 property_schema = next(
207 (item for item in properties_schema if item["key"] == property_key),
208 None,
209 )
210 if property_schema is None: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 return
213 for value_data in property_schema["values"]:
214 if value_data["displayValue"] not in [None, ""]:
215 value_data["value"] = value_data["displayValue"]
217 mole_frac_comp_schema = next(
218 (item for item in properties_schema if item["key"] == "mole_frac_comp"),
219 None
220 )
221 if mole_frac_comp_schema is None: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 return
224 if property_set.compoundMode == "MassFraction":
225 apply_display_value("flow_mass")
227 if not stream_has_build_state_inputs(property_set):
228 # stream is not fully defined, do not convert
229 return
231 def iter_value_entries():
232 values = mole_frac_comp_schema["values"]
233 if isinstance(values, dict): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 for key, value_data in values.items():
235 yield key, value_data
236 return
238 for value_data in values:
239 yield value_data["indexedSets"][0], value_data
241 def convert_to_mass_flow():
242 package_compounds = get_property_set_compounds(property_set)
243 for key, value_data in iter_value_entries():
244 # convert to mass flow
245 if value_data["value"] not in [None, ""]: 245 ↛ 243line 245 didn't jump to line 243 because the condition on line 245 was always true
246 value_data["value"] = compound_db_to_mass_flow(
247 key,
248 float(value_data["value"]),
249 property_set.simulationObject.propertyPackageType,
250 package_compounds,
251 )
253 def convert_to_mass_fraction():
254 # sums for molar fractions and mass fractions should be equal
255 sum_molar_frac = 0
256 for _, value_data in iter_value_entries():
257 sum_molar_frac += float(value_data["value"])
258 convert_to_mass_flow()
259 sum_mass_flow = 0
260 for _, value_data in iter_value_entries():
261 sum_mass_flow += float(value_data["value"])
263 if sum_mass_flow == 0: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true
264 return
265 for _, value_data in iter_value_entries():
266 value_data["value"] = float(value_data["value"]) / sum_mass_flow * sum_molar_frac
268 match property_set.compoundMode:
269 case "MolarFraction": 269 ↛ 270line 269 didn't jump to line 270 because the pattern on line 269 never matched
270 pass # already in molar fractions
271 case "MassFraction": 271 ↛ exitline 271 didn't return from function 'serialize_to_current_mode' because the pattern on line 271 always matched
272 convert_to_mass_fraction()
275def convert_to_molar_fractions(property_set: PropertySet) -> None:
276 """
277 Converts the composition to molar fractions from raw values
278 """
279 mole_frac_comp = property_set.get_property("mole_frac_comp")
280 property_values = mole_frac_comp.values.all()
281 package_compounds = get_property_set_compounds(property_set)
283 def molar_flows_to_fractions() -> None:
284 # convert from molar flows to molar fractions
285 sum_flows = sum([float(prop.value) for prop in property_values])
286 if sum_flows == 0: 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true
287 return
288 for prop in property_values:
289 prop.value = float(prop.value) / sum_flows
291 def mass_flows_to_molar_flows() -> None:
292 # convert from mass flows to molar flows
293 for prop in property_values:
294 if prop.displayValue in [None, ""]:
295 prop.displayValue = prop.value
296 prop.value = compound_db_to_molar_flow(
297 prop.get_index("compound").key,
298 float(prop.displayValue),
299 property_set.simulationObject.propertyPackageType,
300 package_compounds,
301 )
303 match property_set.compoundMode:
304 case "MolarFraction": 304 ↛ 305line 304 didn't jump to line 305 because the pattern on line 304 never matched
305 return # already in molar fractions
306 case "MassFraction": 306 ↛ 311line 306 didn't jump to line 311 because the pattern on line 306 always matched
307 # assume total mass flow of 1
308 mass_flows_to_molar_flows()
309 molar_flows_to_fractions()
311 tracked_bulk_update(
312 PropertyValue.objects,
313 property_values,
314 ["value", "displayValue"],
315 )
318def convert_to_raw_values(property_set: PropertySet) -> None:
319 """
320 Converts the composition to raw values from molar fractions
321 """
322 mole_frac_comp = property_set.get_property("mole_frac_comp")
323 property_values = mole_frac_comp.values.all()
324 for prop in property_values:
325 if prop.displayValue not in [None, ""]:
326 prop.value = float(prop.displayValue)
328 tracked_bulk_update(
329 PropertyValue.objects,
330 property_values,
331 ["value", "displayValue"],
332 )
335def stream_has_build_state_inputs(property_set: PropertySet) -> bool:
336 """Return whether a stream has enough inputs for an async build-state."""
337 try:
338 mole_frac_comp = property_set.get_property("mole_frac_comp")
339 except ValueError:
340 return False
342 has_complete_composition = check_fully_defined(
343 property_set,
344 [mole_frac_comp],
345 check_fraction_sum=True,
346 )
347 if not has_complete_composition:
348 return False
350 for property_pair in property_combinations:
351 if all(property_set.get_property(key).has_value() for key in property_pair):
352 return True
354 return False