Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
293 changes: 292 additions & 1 deletion src/neqsim/thermo/thermoTools.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,10 @@

"""

import importlib
import logging
from typing import List, Optional, Union
from dataclasses import dataclass
from typing import List, Optional, Tuple, Union
import jpype
import pandas
from jpype.types import *
Expand Down Expand Up @@ -315,6 +317,270 @@
}


class ExtendedDatabaseError(Exception):
"""Raised when a component cannot be resolved in the extended database."""


@dataclass
class _ChemicalComponentData:
name: str
CAS: str
tc: float
pc: float
omega: float
molar_mass: Optional[float] = None
normal_boiling_point: Optional[float] = None
triple_point_temperature: Optional[float] = None
critical_volume: Optional[float] = None
critical_compressibility: Optional[float] = None


def _create_extended_database_provider():
"""Create a chemicals database provider."""

return _ChemicalsDatabaseProvider()


class _ChemicalsDatabaseProvider:
"""Lookup component data from the `chemicals` package."""

def __init__(self):
try:
from chemicals.identifiers import CAS_from_any
except ImportError as exc: # pragma: no cover - import guard
raise ModuleNotFoundError(
"The 'chemicals' package is required to use the extended component database."
) from exc

self._cas_from_any = CAS_from_any
critical = importlib.import_module("chemicals.critical")
try:
phase_change = importlib.import_module("chemicals.phase_change")
except ImportError: # pragma: no cover - optional submodule
phase_change = None
try:
elements = importlib.import_module("chemicals.elements")
except ImportError: # pragma: no cover - optional submodule
elements = None

self._tc = getattr(critical, "Tc")
self._pc = getattr(critical, "Pc")
self._omega = getattr(critical, "omega")
self._vc = getattr(critical, "Vc", None)
self._zc = getattr(critical, "Zc", None)
triple_point_candidates = [
getattr(critical, "Ttriple", None),
getattr(critical, "Tt", None),
]
if phase_change is not None:
triple_point_candidates.append(getattr(phase_change, "Tt", None))
self._triple_point = next(
(func for func in triple_point_candidates if func), None
)
self._tb = (
getattr(phase_change, "Tb", None) if phase_change is not None else None
)
self._molecular_weight = (
getattr(elements, "molecular_weight", None)
if elements is not None
else None
)

def get_component(self, name: str) -> _ChemicalComponentData:
cas = self._cas_from_any(name)
if not cas:
raise ExtendedDatabaseError(
f"Component '{name}' was not found in the chemicals database."
)

tc = self._tc(cas)
pc = self._pc(cas)
omega = self._omega(cas)

if None in (tc, pc, omega):
raise ExtendedDatabaseError(
f"Incomplete property data for '{name}' (CAS {cas})."
)

molar_mass = self._call_optional(self._molecular_weight, cas)
if molar_mass is not None:
molar_mass = float(molar_mass) / 1000.0

normal_boiling_point = self._call_optional(self._tb, cas)
triple_point_temperature = self._call_optional(self._triple_point, cas)

critical_volume = self._call_optional(self._vc, cas)
if critical_volume is not None:
critical_volume = float(critical_volume) * 1.0e6 # m^3/mol -> cm^3/mol

critical_compressibility = self._call_optional(self._zc, cas)

return _ChemicalComponentData(
name=name,
CAS=cas,
tc=float(tc),
pc=float(pc) / 1.0e5, # chemicals returns pressure in Pa
omega=float(omega),
molar_mass=molar_mass,
normal_boiling_point=(
float(normal_boiling_point)
if normal_boiling_point is not None
else None
),
triple_point_temperature=(
float(triple_point_temperature)
if triple_point_temperature is not None
else None
),
critical_volume=critical_volume,
critical_compressibility=(
float(critical_compressibility)
if critical_compressibility is not None
else None
),
)

@staticmethod
def _call_optional(func, cas):
if func is None:
return None
for call in (
lambda: func(cas),
lambda: func(CASRN=cas),
):
try:
value = call()
except TypeError:
continue
except Exception: # pragma: no cover - defensive fallback
Copy link

Copilot AI Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching bare Exception silences all errors including unexpected ones. Consider catching specific exceptions that are expected during the function call, or at minimum log the error before returning None so debugging is possible.

Suggested change
except Exception: # pragma: no cover - defensive fallback
except Exception: # pragma: no cover - defensive fallback
logger.exception(f"Unexpected error in _call_optional for func={func} and cas={cas}")

Copilot uses AI. Check for mistakes.
return None
else:
return value
return None


def _get_extended_provider(system):
provider = getattr(system, "_extended_database_provider", None)
if provider is None:
provider = _create_extended_database_provider()
system._extended_database_provider = provider # type: ignore[attr-defined]
return provider


def _apply_extended_properties(
system, component_names: Tuple[str, ...], data: _ChemicalComponentData
):
setter_map = {
"CAS": "setCASnumber",
"molar_mass": "setMolarMass",
"normal_boiling_point": "setNormalBoilingPoint",
"triple_point_temperature": "setTriplePointTemperature",
"critical_volume": "setCriticalVolume",
"critical_compressibility": "setCriticalCompressibilityFactor",
}

for phase_index in range(system.getNumberOfPhases()):
try:
phase = system.getPhase(phase_index)
except Exception: # pragma: no cover - defensive fallback
Copy link

Copilot AI Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching bare Exception and silently continuing could hide critical errors. Consider catching specific exceptions or logging the error to aid debugging when phase retrieval fails unexpectedly.

Suggested change
except Exception: # pragma: no cover - defensive fallback
except Exception as e: # pragma: no cover - defensive fallback
logger.warning(
"Failed to get phase at index %d in system %r: %s",
phase_index, system, e,
exc_info=True
)

Copilot uses AI. Check for mistakes.
continue
if not hasattr(phase, "hasComponent"):
continue
component = None
for name in component_names:
if phase.hasComponent(name):
component = phase.getComponent(name)
break
if component is None:
continue
for field, setter_name in setter_map.items():
value = getattr(data, field, None)
if value is None:
continue
setter = getattr(component, setter_name, None)
if setter is None:
continue
setter(value)


def _system_interface_class():
"""Return the JPype proxy for ``neqsim.thermo.system.SystemInterface``."""

if not hasattr(_system_interface_class, "_cached"):
_system_interface_class._cached = jpype.JClass( # type: ignore[attr-defined]
"neqsim.thermo.system.SystemInterface"
)
return _system_interface_class._cached # type: ignore[attr-defined]


def _resolve_alias(name: str) -> str:
try:
return jneqsim.thermo.component.Component.getComponentNameFromAlias(name)
except Exception: # pragma: no cover - defensive alias resolution
return name
Comment on lines +519 to +520
Copy link

Copilot AI Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching bare Exception silently falls back to the original name. Consider catching specific exceptions (e.g., AttributeError, TypeError) or logging when alias resolution fails to help diagnose configuration issues.

Suggested change
except Exception: # pragma: no cover - defensive alias resolution
return name
except (AttributeError, TypeError) as e: # pragma: no cover - defensive alias resolution
logger.warning(f"Alias resolution failed for '{name}': {e}")
return name
except Exception as e:
logger.error(f"Unexpected error during alias resolution for '{name}': {e}", exc_info=True)
return name

Copilot uses AI. Check for mistakes.


def _has_component_in_database(name: str) -> bool:
database = jneqsim.util.database.NeqSimDataBase
return database.hasComponent(name) or database.hasTempComponent(name)


def _args_look_like_component_properties(args: Tuple[object, ...]) -> bool:
return len(args) == 3 and all(isinstance(value, (int, float)) for value in args)


@jpype.JImplementationFor("neqsim.thermo.system.SystemInterface")
class _SystemInterface:
def useExtendedDatabase(self, enable: bool = True):
"""Enable or disable usage of the chemicals based component database."""

if enable:
provider = _create_extended_database_provider()
self._use_extended_database = True # type: ignore[attr-defined]
self._extended_database_provider = provider # type: ignore[attr-defined]
else:
self._use_extended_database = False # type: ignore[attr-defined]
if hasattr(self, "_extended_database_provider"):
delattr(self, "_extended_database_provider")
return self

def addComponent(self, name, amount, *args): # noqa: N802 - Java signature
alias_name = _resolve_alias(name)
component_data = None

if getattr(
self, "_use_extended_database", False
) and not _has_component_in_database(alias_name):
try:
provider = _get_extended_provider(self)
component_data = provider.get_component(name)
except (ExtendedDatabaseError, ModuleNotFoundError):
component_data = None

if component_data is not None and not _args_look_like_component_properties(
args
):
if args:
raise NotImplementedError(
"Extended database currently supports components specified in moles (unit='no') "
"without explicit phase targeting or alternative units."
)
result = _system_interface_class().addComponent(
self,
name,
float(amount),
component_data.tc,
component_data.pc,
component_data.omega,
)

_apply_extended_properties(self, (alias_name, name), component_data)

return result

return _system_interface_class().addComponent(self, name, amount, *args)


def fluid(name="srk", temperature=298.15, pressure=1.01325):
"""
Create a thermodynamic fluid system.
Expand Down Expand Up @@ -1100,6 +1366,31 @@ def addComponent(thermoSystem, name, moles, unit="no", phase=-10):
Returns:
None
"""
alias_name = _resolve_alias(name)

if getattr(
thermoSystem, "_use_extended_database", False
) and not _has_component_in_database(alias_name):
try:
provider = _get_extended_provider(thermoSystem)
component_data = provider.get_component(name)
except (ExtendedDatabaseError, ModuleNotFoundError):
component_data = None
if component_data is not None:
if unit != "no" or phase != -10:
raise NotImplementedError(
"Extended database currently supports components specified in moles (unit='no') "
"without explicit phase targeting."
)
thermoSystem.addComponent(
name,
moles,
component_data.tc,
component_data.pc,
component_data.omega,
)
return

if phase == -10 and unit == "no":
thermoSystem.addComponent(name, moles)
elif phase == -10:
Expand Down
77 changes: 77 additions & 0 deletions tests/test_extended_database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import pytest


chemicals = pytest.importorskip("chemicals")

import chemicals.critical as critical_data # type: ignore # noqa: E402
from chemicals.critical import Pc, Tc, Vc, Zc, omega # type: ignore # noqa: E402
from chemicals.elements import molecular_weight # type: ignore # noqa: E402
from chemicals.phase_change import Tb # type: ignore # noqa: E402
from chemicals.identifiers import CAS_from_any # type: ignore # noqa: E402

from neqsim.thermo.thermoTools import addComponent, fluid


def test_use_extended_database_allows_missing_component():
system = fluid("srk")

with pytest.raises(Exception):
system.addComponent("dimethylsulfoxide", 1.0)

system.useExtendedDatabase(True)
system.addComponent("dimethylsulfoxide", 1.0)

component = system.getPhase(0).getComponent("dimethylsulfoxide")
cas = CAS_from_any("dimethylsulfoxide")

assert pytest.approx(component.getTC(), rel=1e-6) == Tc(cas)
assert pytest.approx(component.getPC(), rel=1e-6) == Pc(cas) / 1.0e5
assert pytest.approx(component.getAcentricFactor(), rel=1e-6) == omega(cas)

molar_mass = molecular_weight(CASRN=cas)
assert molar_mass is not None
assert pytest.approx(component.getMolarMass(), rel=1e-6) == molar_mass / 1000.0

normal_boiling_point = Tb(cas)
if normal_boiling_point is not None:
assert (
pytest.approx(component.getNormalBoilingPoint(), rel=1e-6)
== normal_boiling_point
)

critical_volume = Vc(cas)
if critical_volume is not None:
assert (
pytest.approx(component.getCriticalVolume(), rel=1e-6)
== critical_volume * 1.0e6
)

critical_compressibility = Zc(cas)
if critical_compressibility is not None:
assert (
pytest.approx(component.getCriticalCompressibilityFactor(), rel=1e-6)
== critical_compressibility
)

triple_point_func = getattr(critical_data, "Ttriple", None) or getattr(
critical_data, "Tt", None
)
if triple_point_func is not None:
triple_point_temperature = triple_point_func(cas)
if triple_point_temperature is not None:
assert (
pytest.approx(component.getTriplePointTemperature(), rel=1e-6)
== triple_point_temperature
)


def test_module_add_component_uses_extended_database():
system = fluid("srk")

with pytest.raises(Exception):
addComponent(system, "dimethylsulfoxide", 1.0)

system.useExtendedDatabase(True)
addComponent(system, "dimethylsulfoxide", 1.0)

assert system.getPhase(0).hasComponent("dimethylsulfoxide")