Skip to content

Commit c15f7b9

Browse files
committed
generalize zeo++ for dictinonary rules; add mofid class
1 parent f6c8cfd commit c15f7b9

File tree

2 files changed

+184
-40
lines changed

2 files changed

+184
-40
lines changed

src/atomate2/common/jobs/mof.py

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,76 @@
1111
import multiprocessing
1212
import os
1313
import subprocess
14+
from collections.abc import Callable
1415
from shutil import which
16+
from tempfile import TemporaryDirectory
1517
from typing import Any
1618

1719
from jobflow import job
20+
from pydantic import BaseModel
1821
from pymatgen.core import Structure
1922

2023
logger = logging.getLogger(__name__)
2124

25+
_installed_extra = {"mofid": True}
26+
try:
27+
from mofid.run_mofid import cif2mofid
28+
except ImportError:
29+
_installed_extra["mofid"] = False
30+
31+
32+
class MofIdEntry(BaseModel):
33+
"""
34+
Interface for running MOFid calculations.
35+
36+
This class wraps the mofid executable to extract key MOF components.
37+
"""
38+
39+
smiles: str | None = None
40+
Topology: str | None = None
41+
SmilesLinkers: list[str] | None = None
42+
SmilesNodes: list[str] | None = None
43+
Mofkey: str | None = None
44+
Mofid: str | None = None
45+
46+
@classmethod
47+
def from_structure(cls, structure: Structure, **kwargs) -> "MofIdEntry":
48+
"""
49+
Run MOFid, `cif2mofid` function, in a temporary directory.
50+
51+
Store MOFid information: MOF topology, linker and metal nodes SMILES.
52+
"""
53+
if not _installed_extra["mofid"]:
54+
logger.debug("MOFid not found, skipping MOFid analysis.")
55+
return cls()
56+
old_cwd = os.getcwd()
57+
try:
58+
with TemporaryDirectory() as tmp:
59+
os.chdir(tmp)
60+
structure.to("tmp.cif")
61+
mofid_out = cif2mofid("tmp.cif", **kwargs)
62+
except Exception as exc: # noqa: BLE001
63+
logger.warning("MOFid failed: %s", exc)
64+
return cls()
65+
os.chdir(old_cwd)
66+
67+
remap = {
68+
"Smiles": "smiles",
69+
"Topology": "topology",
70+
"SmilesLinkers": "smiles_linkers",
71+
"SmilesNodes": "smiles_nodes",
72+
"MofKey": "mofkey",
73+
"MofId": "mofid",
74+
}
75+
return cls(**{k: mofid_out.get(v) for k, v in remap.items()})
76+
2277

2378
class ZeoPlusPlus:
2479
"""
2580
Interface for running zeo++ calculations for MOF or zeolites.
2681
2782
This class wraps the zeo++ executable to calculate pore properties
83+
(e.g, Probe-occupiable volume, Pore diameters - see zeoplusplus.org)
2884
using given sorbate species.
2985
"""
3086

@@ -347,9 +403,10 @@ def run_zeopp_assessment(
347403
sorbates: list[str] | str | None = None,
348404
cif_name: str | None = None,
349405
nproc: int = 1,
406+
rules: dict[str, Callable[[dict[str, Any]], bool]] | None = None,
350407
) -> dict[str, Any]:
351408
"""
352-
Run zeo++ MOF assessment on a structure.
409+
Run zeo++ on a structure with user-defined rules.
353410
354411
Parameters
355412
----------
@@ -365,11 +422,38 @@ def run_zeopp_assessment(
365422
Filename for the CIF if structure is a Structure.
366423
nproc : int, optional
367424
Number of processes to use.
425+
rules : dict[str, Callable[[dict[str, Any]], bool]], optional
426+
Mapping of names to functions that take the full output dict
427+
and return True/False if the structure passes each rule.
368428
369429
Returns
370430
-------
371431
dict[str, Any]
372-
Dictionary containing zeo++ outputs for each sorbate and a flag 'is_mof'.
432+
Zeo++ outputs (per sorbate) and boolean result for the rule.
433+
434+
Examples
435+
--------
436+
Example of custom rules to assess a candidate MOF structure:
437+
438+
```python
439+
from atomate2.common.jobs.mof import run_zeopp_assessment
440+
441+
442+
def custom_mof_rule(out):
443+
props = out["N2"]
444+
keys = ["PLD", "POAV_A^3", "PONAV_A^3"]
445+
if not all(k in props for k in keys):
446+
return False
447+
return props["PLD"] > 3.0
448+
449+
450+
response = run_zeopp_assessment(
451+
structure=my_struct,
452+
sorbates="N2",
453+
rules={"is_mof": custom_mof_rule},
454+
)
455+
# response.output["is_mof"] will be True/False
456+
```
373457
"""
374458
if sorbates is None:
375459
sorbates = ["N2", "CO2", "H2O"]
@@ -399,23 +483,11 @@ def run_zeopp_assessment(
399483
for sorbate in maker.sorbates:
400484
output[sorbate].update(maker.output[sorbate])
401485

402-
output["is_mof"] = False
403-
if all(
404-
k in output["N2"]
405-
for k in (
406-
"PLD",
407-
"POAV_A^3",
408-
"PONAV_A^3",
409-
"POAV_Volume_fraction",
410-
"PONAV_Volume_fraction",
411-
)
412-
):
413-
output["is_mof"] = (
414-
output["N2"]["PLD"] > 2.5
415-
and output["N2"]["POAV_Volume_fraction"] > 0.3
416-
and output["N2"]["POAV_A^3"] > output["N2"]["PONAV_A^3"]
417-
and output["N2"]["POAV_Volume_fraction"]
418-
> output["N2"]["PONAV_Volume_fraction"]
419-
)
486+
if rules is not None:
487+
for name, rule_func in rules.items():
488+
try:
489+
output[name] = bool(rule_func(output))
490+
except Exception as e: # noqa: BLE001
491+
output[name] = f"rule_error: {e!s}"
420492

421493
return output

tests/common/test_mof.py

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,76 @@
1111
import multiprocessing
1212
import os
1313
import subprocess
14+
from collections.abc import Callable
1415
from shutil import which
16+
from tempfile import TemporaryDirectory
1517
from typing import Any
1618

1719
from jobflow import job
20+
from pydantic import BaseModel
1821
from pymatgen.core import Structure
1922

2023
logger = logging.getLogger(__name__)
2124

25+
_installed_extra = {"mofid": True}
26+
try:
27+
from mofid.run_mofid import cif2mofid
28+
except ImportError:
29+
_installed_extra["mofid"] = False
30+
31+
32+
class MofIdEntry(BaseModel):
33+
"""
34+
Interface for running MOFid calculations.
35+
36+
This class wraps the mofid executable to extract key MOF components.
37+
"""
38+
39+
smiles: str | None = None
40+
Topology: str | None = None
41+
SmilesLinkers: list[str] | None = None
42+
SmilesNodes: list[str] | None = None
43+
Mofkey: str | None = None
44+
Mofid: str | None = None
45+
46+
@classmethod
47+
def from_structure(cls, structure: Structure, **kwargs) -> "MofIdEntry":
48+
"""
49+
Run MOFid, `cif2mofid` function, in a temporary directory.
50+
51+
Store MOFid information: MOF topology, linker and metal nodes SMILES.
52+
"""
53+
if not _installed_extra["mofid"]:
54+
logger.debug("MOFid not found, skipping MOFid analysis.")
55+
return cls()
56+
old_cwd = os.getcwd()
57+
try:
58+
with TemporaryDirectory() as tmp:
59+
os.chdir(tmp)
60+
structure.to("tmp.cif")
61+
mofid_out = cif2mofid("tmp.cif", **kwargs)
62+
except Exception as exc: # noqa: BLE001
63+
logger.warning("MOFid failed: %s", exc)
64+
return cls()
65+
os.chdir(old_cwd)
66+
67+
remap = {
68+
"Smiles": "smiles",
69+
"Topology": "topology",
70+
"SmilesLinkers": "smiles_linkers",
71+
"SmilesNodes": "smiles_nodes",
72+
"MofKey": "mofkey",
73+
"MofId": "mofid",
74+
}
75+
return cls(**{k: mofid_out.get(v) for k, v in remap.items()})
76+
2277

2378
class ZeoPlusPlus:
2479
"""
2580
Interface for running zeo++ calculations for MOF or zeolites.
2681
2782
This class wraps the zeo++ executable to calculate pore properties
83+
(e.g, Probe-occupiable volume, Pore diameters - see zeoplusplus.org)
2884
using given sorbate species.
2985
"""
3086

@@ -347,9 +403,10 @@ def run_zeopp_assessment(
347403
sorbates: list[str] | str | None = None,
348404
cif_name: str | None = None,
349405
nproc: int = 1,
406+
rules: dict[str, Callable[[dict[str, Any]], bool]] | None = None,
350407
) -> dict[str, Any]:
351408
"""
352-
Run zeo++ MOF assessment on a structure.
409+
Run zeo++ on a structure with user-defined rules.
353410
354411
Parameters
355412
----------
@@ -365,11 +422,38 @@ def run_zeopp_assessment(
365422
Filename for the CIF if structure is a Structure.
366423
nproc : int, optional
367424
Number of processes to use.
425+
rules : dict[str, Callable[[dict[str, Any]], bool]], optional
426+
Mapping of names to functions that take the full output dict
427+
and return True/False if the structure passes each rule.
368428
369429
Returns
370430
-------
371431
dict[str, Any]
372-
Dictionary containing zeo++ outputs for each sorbate and a flag 'is_mof'.
432+
Zeo++ outputs (per sorbate) and boolean result for the rule.
433+
434+
Examples
435+
--------
436+
Example of custom rules to assess a candidate MOF structure:
437+
438+
```python
439+
from atomate2.common.jobs.mof import run_zeopp_assessment
440+
441+
442+
def custom_mof_rule(out):
443+
props = out["N2"]
444+
keys = ["PLD", "POAV_A^3", "PONAV_A^3"]
445+
if not all(k in props for k in keys):
446+
return False
447+
return props["PLD"] > 3.0
448+
449+
450+
response = run_zeopp_assessment(
451+
structure=my_struct,
452+
sorbates="N2",
453+
rules={"is_mof": custom_mof_rule},
454+
)
455+
# response.output["is_mof"] will be True/False
456+
```
373457
"""
374458
if sorbates is None:
375459
sorbates = ["N2", "CO2", "H2O"]
@@ -399,23 +483,11 @@ def run_zeopp_assessment(
399483
for sorbate in maker.sorbates:
400484
output[sorbate].update(maker.output[sorbate])
401485

402-
output["is_mof"] = False
403-
if all(
404-
k in output["N2"]
405-
for k in (
406-
"PLD",
407-
"POAV_A^3",
408-
"PONAV_A^3",
409-
"POAV_Volume_fraction",
410-
"PONAV_Volume_fraction",
411-
)
412-
):
413-
output["is_mof"] = (
414-
output["N2"]["PLD"] > 2.5
415-
and output["N2"]["POAV_Volume_fraction"] > 0.3
416-
and output["N2"]["POAV_A^3"] > output["N2"]["PONAV_A^3"]
417-
and output["N2"]["POAV_Volume_fraction"]
418-
> output["N2"]["PONAV_Volume_fraction"]
419-
)
486+
if rules is not None:
487+
for name, rule_func in rules.items():
488+
try:
489+
output[name] = bool(rule_func(output))
490+
except Exception as e: # noqa: BLE001
491+
output[name] = f"rule_error: {e!s}"
420492

421493
return output

0 commit comments

Comments
 (0)