|
265 | 265 |
|
266 | 266 | """ |
267 | 267 |
|
| 268 | +import importlib |
268 | 269 | import logging |
269 | | -from typing import List, Optional, Union |
| 270 | +from dataclasses import dataclass |
| 271 | +from typing import List, Optional, Tuple, Union |
270 | 272 | import jpype |
271 | 273 | import pandas |
272 | 274 | from jpype.types import * |
|
315 | 317 | } |
316 | 318 |
|
317 | 319 |
|
| 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 | + |
318 | 584 | def fluid(name="srk", temperature=298.15, pressure=1.01325): |
319 | 585 | """ |
320 | 586 | Create a thermodynamic fluid system. |
@@ -1100,6 +1366,31 @@ def addComponent(thermoSystem, name, moles, unit="no", phase=-10): |
1100 | 1366 | Returns: |
1101 | 1367 | None |
1102 | 1368 | """ |
| 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 | + |
1103 | 1394 | if phase == -10 and unit == "no": |
1104 | 1395 | thermoSystem.addComponent(name, moles) |
1105 | 1396 | elif phase == -10: |
|
0 commit comments