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

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) 

8 

9 

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) 

24 

25 

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. 

32 

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 

41 

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 ) 

46 

47 return get_component_molecular_weight( 

48 property_package, 

49 package_compounds, 

50 name, 

51 ) 

52 

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 ) 

61 

62 

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 

79 

80 

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 

97 

98 

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 ) 

106 

107 

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) 

116 

117 # Calculate mass for each compound and track total mass 

118 compound_masses = [] 

119 total_mass = 0.0 

120 

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 

124 

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 ) 

132 

133 # Calculate mass (molar fraction * molecular weight) 

134 mass = molar_fraction * molecular_weight 

135 compound_masses.append(mass) 

136 total_mass += mass 

137 

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) 

145 

146 # Update in database 

147 tracked_bulk_update(PropertyValue.objects, property_values, ["displayValue"]) 

148 

149 

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

163 

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 

196 

197 return True 

198 

199 

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 

212 

213 for value_data in property_schema["values"]: 

214 if value_data["displayValue"] not in [None, ""]: 

215 value_data["value"] = value_data["displayValue"] 

216 

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 

223 

224 if property_set.compoundMode == "MassFraction": 

225 apply_display_value("flow_mass") 

226 

227 if not stream_has_build_state_inputs(property_set): 

228 # stream is not fully defined, do not convert 

229 return 

230 

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 

237 

238 for value_data in values: 

239 yield value_data["indexedSets"][0], value_data 

240 

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 ) 

252 

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"]) 

262 

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 

267 

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

273 

274 

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) 

282 

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 

290 

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 ) 

302 

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

310 

311 tracked_bulk_update( 

312 PropertyValue.objects, 

313 property_values, 

314 ["value", "displayValue"], 

315 ) 

316 

317 

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) 

327 

328 tracked_bulk_update( 

329 PropertyValue.objects, 

330 property_values, 

331 ["value", "displayValue"], 

332 ) 

333 

334 

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 

341 

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 

349 

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 

353 

354 return False