Skip to content

Commit cf55233

Browse files
authored
Merge pull request #338 from equinor/option-to-import-component-properties-from-chemicals-database2
option to import component properties from chemicals database
2 parents 36016a1 + 76232a3 commit cf55233

File tree

2 files changed

+369
-1
lines changed

2 files changed

+369
-1
lines changed

src/neqsim/thermo/thermoTools.py

Lines changed: 292 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,10 @@
265265
266266
"""
267267

268+
import importlib
268269
import logging
269-
from typing import List, Optional, Union
270+
from dataclasses import dataclass
271+
from typing import List, Optional, Tuple, Union
270272
import jpype
271273
import pandas
272274
from jpype.types import *
@@ -315,6 +317,270 @@
315317
}
316318

317319

320+
class ExtendedDatabaseError(Exception):
321+
"""Raised when a component cannot be resolved in the extended database."""
322+
323+
324+
@dataclass
325+
class _ChemicalComponentData:
326+
name: str
327+
CAS: str
328+
tc: float
329+
pc: float
330+
omega: float
331+
molar_mass: Optional[float] = None
332+
normal_boiling_point: Optional[float] = None
333+
triple_point_temperature: Optional[float] = None
334+
critical_volume: Optional[float] = None
335+
critical_compressibility: Optional[float] = None
336+
337+
338+
def _create_extended_database_provider():
339+
"""Create a chemicals database provider."""
340+
341+
return _ChemicalsDatabaseProvider()
342+
343+
344+
class _ChemicalsDatabaseProvider:
345+
"""Lookup component data from the `chemicals` package."""
346+
347+
def __init__(self):
348+
try:
349+
from chemicals.identifiers import CAS_from_any
350+
except ImportError as exc: # pragma: no cover - import guard
351+
raise ModuleNotFoundError(
352+
"The 'chemicals' package is required to use the extended component database."
353+
) from exc
354+
355+
self._cas_from_any = CAS_from_any
356+
critical = importlib.import_module("chemicals.critical")
357+
try:
358+
phase_change = importlib.import_module("chemicals.phase_change")
359+
except ImportError: # pragma: no cover - optional submodule
360+
phase_change = None
361+
try:
362+
elements = importlib.import_module("chemicals.elements")
363+
except ImportError: # pragma: no cover - optional submodule
364+
elements = None
365+
366+
self._tc = getattr(critical, "Tc")
367+
self._pc = getattr(critical, "Pc")
368+
self._omega = getattr(critical, "omega")
369+
self._vc = getattr(critical, "Vc", None)
370+
self._zc = getattr(critical, "Zc", None)
371+
triple_point_candidates = [
372+
getattr(critical, "Ttriple", None),
373+
getattr(critical, "Tt", None),
374+
]
375+
if phase_change is not None:
376+
triple_point_candidates.append(getattr(phase_change, "Tt", None))
377+
self._triple_point = next(
378+
(func for func in triple_point_candidates if func), None
379+
)
380+
self._tb = (
381+
getattr(phase_change, "Tb", None) if phase_change is not None else None
382+
)
383+
self._molecular_weight = (
384+
getattr(elements, "molecular_weight", None)
385+
if elements is not None
386+
else None
387+
)
388+
389+
def get_component(self, name: str) -> _ChemicalComponentData:
390+
cas = self._cas_from_any(name)
391+
if not cas:
392+
raise ExtendedDatabaseError(
393+
f"Component '{name}' was not found in the chemicals database."
394+
)
395+
396+
tc = self._tc(cas)
397+
pc = self._pc(cas)
398+
omega = self._omega(cas)
399+
400+
if None in (tc, pc, omega):
401+
raise ExtendedDatabaseError(
402+
f"Incomplete property data for '{name}' (CAS {cas})."
403+
)
404+
405+
molar_mass = self._call_optional(self._molecular_weight, cas)
406+
if molar_mass is not None:
407+
molar_mass = float(molar_mass) / 1000.0
408+
409+
normal_boiling_point = self._call_optional(self._tb, cas)
410+
triple_point_temperature = self._call_optional(self._triple_point, cas)
411+
412+
critical_volume = self._call_optional(self._vc, cas)
413+
if critical_volume is not None:
414+
critical_volume = float(critical_volume) * 1.0e6 # m^3/mol -> cm^3/mol
415+
416+
critical_compressibility = self._call_optional(self._zc, cas)
417+
418+
return _ChemicalComponentData(
419+
name=name,
420+
CAS=cas,
421+
tc=float(tc),
422+
pc=float(pc) / 1.0e5, # chemicals returns pressure in Pa
423+
omega=float(omega),
424+
molar_mass=molar_mass,
425+
normal_boiling_point=(
426+
float(normal_boiling_point)
427+
if normal_boiling_point is not None
428+
else None
429+
),
430+
triple_point_temperature=(
431+
float(triple_point_temperature)
432+
if triple_point_temperature is not None
433+
else None
434+
),
435+
critical_volume=critical_volume,
436+
critical_compressibility=(
437+
float(critical_compressibility)
438+
if critical_compressibility is not None
439+
else None
440+
),
441+
)
442+
443+
@staticmethod
444+
def _call_optional(func, cas):
445+
if func is None:
446+
return None
447+
for call in (
448+
lambda: func(cas),
449+
lambda: func(CASRN=cas),
450+
):
451+
try:
452+
value = call()
453+
except TypeError:
454+
continue
455+
except Exception: # pragma: no cover - defensive fallback
456+
return None
457+
else:
458+
return value
459+
return None
460+
461+
462+
def _get_extended_provider(system):
463+
provider = getattr(system, "_extended_database_provider", None)
464+
if provider is None:
465+
provider = _create_extended_database_provider()
466+
system._extended_database_provider = provider # type: ignore[attr-defined]
467+
return provider
468+
469+
470+
def _apply_extended_properties(
471+
system, component_names: Tuple[str, ...], data: _ChemicalComponentData
472+
):
473+
setter_map = {
474+
"CAS": "setCASnumber",
475+
"molar_mass": "setMolarMass",
476+
"normal_boiling_point": "setNormalBoilingPoint",
477+
"triple_point_temperature": "setTriplePointTemperature",
478+
"critical_volume": "setCriticalVolume",
479+
"critical_compressibility": "setCriticalCompressibilityFactor",
480+
}
481+
482+
for phase_index in range(system.getNumberOfPhases()):
483+
try:
484+
phase = system.getPhase(phase_index)
485+
except Exception: # pragma: no cover - defensive fallback
486+
continue
487+
if not hasattr(phase, "hasComponent"):
488+
continue
489+
component = None
490+
for name in component_names:
491+
if phase.hasComponent(name):
492+
component = phase.getComponent(name)
493+
break
494+
if component is None:
495+
continue
496+
for field, setter_name in setter_map.items():
497+
value = getattr(data, field, None)
498+
if value is None:
499+
continue
500+
setter = getattr(component, setter_name, None)
501+
if setter is None:
502+
continue
503+
setter(value)
504+
505+
506+
def _system_interface_class():
507+
"""Return the JPype proxy for ``neqsim.thermo.system.SystemInterface``."""
508+
509+
if not hasattr(_system_interface_class, "_cached"):
510+
_system_interface_class._cached = jpype.JClass( # type: ignore[attr-defined]
511+
"neqsim.thermo.system.SystemInterface"
512+
)
513+
return _system_interface_class._cached # type: ignore[attr-defined]
514+
515+
516+
def _resolve_alias(name: str) -> str:
517+
try:
518+
return jneqsim.thermo.component.Component.getComponentNameFromAlias(name)
519+
except Exception: # pragma: no cover - defensive alias resolution
520+
return name
521+
522+
523+
def _has_component_in_database(name: str) -> bool:
524+
database = jneqsim.util.database.NeqSimDataBase
525+
return database.hasComponent(name) or database.hasTempComponent(name)
526+
527+
528+
def _args_look_like_component_properties(args: Tuple[object, ...]) -> bool:
529+
return len(args) == 3 and all(isinstance(value, (int, float)) for value in args)
530+
531+
532+
@jpype.JImplementationFor("neqsim.thermo.system.SystemInterface")
533+
class _SystemInterface:
534+
def useExtendedDatabase(self, enable: bool = True):
535+
"""Enable or disable usage of the chemicals based component database."""
536+
537+
if enable:
538+
provider = _create_extended_database_provider()
539+
self._use_extended_database = True # type: ignore[attr-defined]
540+
self._extended_database_provider = provider # type: ignore[attr-defined]
541+
else:
542+
self._use_extended_database = False # type: ignore[attr-defined]
543+
if hasattr(self, "_extended_database_provider"):
544+
delattr(self, "_extended_database_provider")
545+
return self
546+
547+
def addComponent(self, name, amount, *args): # noqa: N802 - Java signature
548+
alias_name = _resolve_alias(name)
549+
component_data = None
550+
551+
if getattr(
552+
self, "_use_extended_database", False
553+
) and not _has_component_in_database(alias_name):
554+
try:
555+
provider = _get_extended_provider(self)
556+
component_data = provider.get_component(name)
557+
except (ExtendedDatabaseError, ModuleNotFoundError):
558+
component_data = None
559+
560+
if component_data is not None and not _args_look_like_component_properties(
561+
args
562+
):
563+
if args:
564+
raise NotImplementedError(
565+
"Extended database currently supports components specified in moles (unit='no') "
566+
"without explicit phase targeting or alternative units."
567+
)
568+
result = _system_interface_class().addComponent(
569+
self,
570+
name,
571+
float(amount),
572+
component_data.tc,
573+
component_data.pc,
574+
component_data.omega,
575+
)
576+
577+
_apply_extended_properties(self, (alias_name, name), component_data)
578+
579+
return result
580+
581+
return _system_interface_class().addComponent(self, name, amount, *args)
582+
583+
318584
def fluid(name="srk", temperature=298.15, pressure=1.01325):
319585
"""
320586
Create a thermodynamic fluid system.
@@ -1100,6 +1366,31 @@ def addComponent(thermoSystem, name, moles, unit="no", phase=-10):
11001366
Returns:
11011367
None
11021368
"""
1369+
alias_name = _resolve_alias(name)
1370+
1371+
if getattr(
1372+
thermoSystem, "_use_extended_database", False
1373+
) and not _has_component_in_database(alias_name):
1374+
try:
1375+
provider = _get_extended_provider(thermoSystem)
1376+
component_data = provider.get_component(name)
1377+
except (ExtendedDatabaseError, ModuleNotFoundError):
1378+
component_data = None
1379+
if component_data is not None:
1380+
if unit != "no" or phase != -10:
1381+
raise NotImplementedError(
1382+
"Extended database currently supports components specified in moles (unit='no') "
1383+
"without explicit phase targeting."
1384+
)
1385+
thermoSystem.addComponent(
1386+
name,
1387+
moles,
1388+
component_data.tc,
1389+
component_data.pc,
1390+
component_data.omega,
1391+
)
1392+
return
1393+
11031394
if phase == -10 and unit == "no":
11041395
thermoSystem.addComponent(name, moles)
11051396
elif phase == -10:

tests/test_extended_database.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
3+
4+
chemicals = pytest.importorskip("chemicals")
5+
6+
import chemicals.critical as critical_data # type: ignore # noqa: E402
7+
from chemicals.critical import Pc, Tc, Vc, Zc, omega # type: ignore # noqa: E402
8+
from chemicals.elements import molecular_weight # type: ignore # noqa: E402
9+
from chemicals.phase_change import Tb # type: ignore # noqa: E402
10+
from chemicals.identifiers import CAS_from_any # type: ignore # noqa: E402
11+
12+
from neqsim.thermo.thermoTools import addComponent, fluid
13+
14+
15+
def test_use_extended_database_allows_missing_component():
16+
system = fluid("srk")
17+
18+
with pytest.raises(Exception):
19+
system.addComponent("dimethylsulfoxide", 1.0)
20+
21+
system.useExtendedDatabase(True)
22+
system.addComponent("dimethylsulfoxide", 1.0)
23+
24+
component = system.getPhase(0).getComponent("dimethylsulfoxide")
25+
cas = CAS_from_any("dimethylsulfoxide")
26+
27+
assert pytest.approx(component.getTC(), rel=1e-6) == Tc(cas)
28+
assert pytest.approx(component.getPC(), rel=1e-6) == Pc(cas) / 1.0e5
29+
assert pytest.approx(component.getAcentricFactor(), rel=1e-6) == omega(cas)
30+
31+
molar_mass = molecular_weight(CASRN=cas)
32+
assert molar_mass is not None
33+
assert pytest.approx(component.getMolarMass(), rel=1e-6) == molar_mass / 1000.0
34+
35+
normal_boiling_point = Tb(cas)
36+
if normal_boiling_point is not None:
37+
assert (
38+
pytest.approx(component.getNormalBoilingPoint(), rel=1e-6)
39+
== normal_boiling_point
40+
)
41+
42+
critical_volume = Vc(cas)
43+
if critical_volume is not None:
44+
assert (
45+
pytest.approx(component.getCriticalVolume(), rel=1e-6)
46+
== critical_volume * 1.0e6
47+
)
48+
49+
critical_compressibility = Zc(cas)
50+
if critical_compressibility is not None:
51+
assert (
52+
pytest.approx(component.getCriticalCompressibilityFactor(), rel=1e-6)
53+
== critical_compressibility
54+
)
55+
56+
triple_point_func = getattr(critical_data, "Ttriple", None) or getattr(
57+
critical_data, "Tt", None
58+
)
59+
if triple_point_func is not None:
60+
triple_point_temperature = triple_point_func(cas)
61+
if triple_point_temperature is not None:
62+
assert (
63+
pytest.approx(component.getTriplePointTemperature(), rel=1e-6)
64+
== triple_point_temperature
65+
)
66+
67+
68+
def test_module_add_component_uses_extended_database():
69+
system = fluid("srk")
70+
71+
with pytest.raises(Exception):
72+
addComponent(system, "dimethylsulfoxide", 1.0)
73+
74+
system.useExtendedDatabase(True)
75+
addComponent(system, "dimethylsulfoxide", 1.0)
76+
77+
assert system.getPhase(0).hasComponent("dimethylsulfoxide")

0 commit comments

Comments
 (0)