From e54600d302ecd06288f07c1be620f494e6935726 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 21:52:19 +0800 Subject: [PATCH 01/29] Modernize packaging and add smoke tests --- Groupy.egg-info/PKG-INFO | 21 - Groupy.egg-info/SOURCES.txt | 19 - Groupy.egg-info/dependency_links.txt | 1 - Groupy.egg-info/entry_points.txt | 2 - Groupy.egg-info/requires.txt | 7 - Groupy.egg-info/top_level.txt | 1 - README.md | 8 +- REFACTORING_PLAN.md | 130 + build/lib/groupy/__init__.py | 7 - build/lib/groupy/gp_3x_calculator.py | 288 --- build/lib/groupy/gp_3x_convertor.py | 345 --- build/lib/groupy/gp_3x_counter.py | 3380 -------------------------- build/lib/groupy/gp_3x_generator.py | 222 -- build/lib/groupy/gp_3x_loader.py | 72 - build/lib/groupy/gp_3x_tool.py | 56 - build/lib/groupy/gp_3x_viewer.py | 96 - build/lib/groupy/groupy.py | 559 ----- build/lib/src/__init__.py | 0 build/lib/src/gp_3x_calculator.py | 288 --- build/lib/src/gp_3x_convertor.py | 345 --- build/lib/src/gp_3x_counter.py | 3374 ------------------------- build/lib/src/gp_3x_generator.py | 222 -- build/lib/src/gp_3x_loader.py | 31 - build/lib/src/gp_3x_tool.py | 56 - build/lib/src/gp_3x_viewer.py | 96 - build/lib/src/groupy.py | 559 ----- build/scripts-3.11/groupy.py | 559 ----- dist/Groupy-3.0.0-py3-none-any.whl | Bin 281830 -> 0 bytes dist/groupy-3.0.0.tar.gz | Bin 212614 -> 0 bytes groupy/__init__.py | 20 +- groupy/gp_loader.py | 62 +- groupy/gp_tool.py | 6 +- pyproject.toml | 40 + setup.py | 34 +- tests/test_smoke.py | 86 + 35 files changed, 306 insertions(+), 10686 deletions(-) delete mode 100644 Groupy.egg-info/PKG-INFO delete mode 100644 Groupy.egg-info/SOURCES.txt delete mode 100644 Groupy.egg-info/dependency_links.txt delete mode 100644 Groupy.egg-info/entry_points.txt delete mode 100644 Groupy.egg-info/requires.txt delete mode 100644 Groupy.egg-info/top_level.txt create mode 100644 REFACTORING_PLAN.md delete mode 100644 build/lib/groupy/__init__.py delete mode 100644 build/lib/groupy/gp_3x_calculator.py delete mode 100644 build/lib/groupy/gp_3x_convertor.py delete mode 100644 build/lib/groupy/gp_3x_counter.py delete mode 100644 build/lib/groupy/gp_3x_generator.py delete mode 100644 build/lib/groupy/gp_3x_loader.py delete mode 100644 build/lib/groupy/gp_3x_tool.py delete mode 100644 build/lib/groupy/gp_3x_viewer.py delete mode 100644 build/lib/groupy/groupy.py delete mode 100644 build/lib/src/__init__.py delete mode 100644 build/lib/src/gp_3x_calculator.py delete mode 100644 build/lib/src/gp_3x_convertor.py delete mode 100644 build/lib/src/gp_3x_counter.py delete mode 100644 build/lib/src/gp_3x_generator.py delete mode 100644 build/lib/src/gp_3x_loader.py delete mode 100644 build/lib/src/gp_3x_tool.py delete mode 100644 build/lib/src/gp_3x_viewer.py delete mode 100644 build/lib/src/groupy.py delete mode 100644 build/scripts-3.11/groupy.py delete mode 100644 dist/Groupy-3.0.0-py3-none-any.whl delete mode 100644 dist/groupy-3.0.0.tar.gz create mode 100644 pyproject.toml create mode 100644 tests/test_smoke.py diff --git a/Groupy.egg-info/PKG-INFO b/Groupy.egg-info/PKG-INFO deleted file mode 100644 index 986c57b..0000000 --- a/Groupy.egg-info/PKG-INFO +++ /dev/null @@ -1,21 +0,0 @@ -Metadata-Version: 2.2 -Name: Groupy -Version: 3.0.0 -Summary: Groupy -- A Useful Tool for Molecular Analysis -Home-page: https://github.com/47-5/Groupy -Author: Ruichen Liu -Author-email: 1197748182@qq.com -Requires-Python: >3.5 -Requires-Dist: rdkit -Requires-Dist: pandas -Requires-Dist: numpy -Requires-Dist: tqdm -Requires-Dist: ase -Requires-Dist: joblib -Requires-Dist: openpyxl -Dynamic: author -Dynamic: author-email -Dynamic: home-page -Dynamic: requires-dist -Dynamic: requires-python -Dynamic: summary diff --git a/Groupy.egg-info/SOURCES.txt b/Groupy.egg-info/SOURCES.txt deleted file mode 100644 index 249859f..0000000 --- a/Groupy.egg-info/SOURCES.txt +++ /dev/null @@ -1,19 +0,0 @@ -README.md -setup.py -Groupy.egg-info/PKG-INFO -Groupy.egg-info/SOURCES.txt -Groupy.egg-info/dependency_links.txt -Groupy.egg-info/entry_points.txt -Groupy.egg-info/requires.txt -Groupy.egg-info/top_level.txt -groupy/__init__.py -groupy/gp_calculator.py -groupy/gp_convertor.py -groupy/gp_counter.py -groupy/gp_generator.py -groupy/gp_loader.py -groupy/gp_tool.py -groupy/gp_viewer.py -groupy/group_contribution_parameters.xlsx -groupy/group_order.xlsx -groupy/groupy_main.py \ No newline at end of file diff --git a/Groupy.egg-info/dependency_links.txt b/Groupy.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/Groupy.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Groupy.egg-info/entry_points.txt b/Groupy.egg-info/entry_points.txt deleted file mode 100644 index df1c40c..0000000 --- a/Groupy.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -Groupy = groupy.groupy_main:main diff --git a/Groupy.egg-info/requires.txt b/Groupy.egg-info/requires.txt deleted file mode 100644 index eef2c7e..0000000 --- a/Groupy.egg-info/requires.txt +++ /dev/null @@ -1,7 +0,0 @@ -rdkit -pandas -numpy -tqdm -ase -joblib -openpyxl diff --git a/Groupy.egg-info/top_level.txt b/Groupy.egg-info/top_level.txt deleted file mode 100644 index 3635b43..0000000 --- a/Groupy.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -groupy diff --git a/README.md b/README.md index ba15664..fca1940 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ One may create an environment using Anaconda: `conda activate groupy_env` -Install: +Install for development: -`pip install .\Groupy\dist\groupy-3.0.0.tar.gz` +`python -m pip install -e .` `conda install -c conda-forge openbabel`(**Do not** use `pip install openbabel` ) -Then one can enter `Groupy` in terminal to start the Groupy. +Then one can enter `Groupy` in terminal to start Groupy. ### Manual and Documention The user manual is in the manual folder, and the API documentation can be found in the doc folder. @@ -26,4 +26,4 @@ The user manual is in the manual folder, and the API documentation can be found # Known limitation when calculating properties of molecules, *simultaneous* type parameters may lead to some mistake results, so we set the -default parameter type is *stepwise* \ No newline at end of file +default parameter type is *stepwise* diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 0000000..ca3a805 --- /dev/null +++ b/REFACTORING_PLAN.md @@ -0,0 +1,130 @@ +# Groupy Refactoring Plan + +Created: 2026-06-24 + +This document captures the agreed refactoring direction for Groupy. The guiding principle is to stabilize behavior first, then modernize structure in small, verifiable steps. + +## Goals + +- Make local development work with `python -m pip install -e .`. +- Keep existing scientific behavior stable while changing project structure. +- Separate library APIs from command-line interaction. +- Improve maintainability without rewriting the chemistry logic all at once. +- Build a minimal regression test suite before deeper refactors. + +## Phase 1: Packaging And Repository Hygiene + +- [x] Keep `pyproject.toml` as the canonical packaging metadata. +- [x] Keep `setup.py` only as a legacy compatibility shim, or remove it once no longer needed. +- [x] Remove generated artifacts from Git tracking: + - `build/` + - `dist/` + - `Groupy.egg-info/` +- [x] Confirm `.gitignore` continues to ignore those generated paths. +- [x] Verify: + - `python -m pip install -e .` + - `python -m pip show Groupy` + - `python -m compileall groupy` + +## Phase 2: Regression Test Baseline + +Add a small smoke-test suite before changing core behavior. + +- [x] Add a test baseline in `tests/test_smoke.py`. +- [x] Add `pytest` to the `dev` optional dependency group. +- [x] Test that `Loader` can load bundled Excel data. +- [x] Test that `Counter().count_a_mol("C1CCCC1")` returns a stable result. +- [x] Test that `Calculator().calculate_a_mol("C1CCCC1")` completes and returns expected keys. +- [x] Test `Tool.load_smiles_iterator()` for: + - `.txt` + - `.csv` + - `.xlsx` +- [x] Test that the console entry point starts and can exit cleanly. + +Current test command: + +```powershell +python -W error::ResourceWarning -m unittest discover -s tests +``` + +## Phase 3: Separate Core API From CLI + +The current `groupy_main.py` is a fully interactive menu. Preserve it initially, but introduce a modern CLI layer around the existing API. + +- [ ] Add `groupy/cli.py`. +- [ ] Move console entry point from `groupy.groupy_main:main` to the new CLI once ready. +- [ ] Preserve the old interactive menu as a subcommand or compatibility path. +- [ ] Add scriptable commands such as: + - `groupy count --smiles C1CCCC1` + - `groupy calculate --input SMILES.txt --output result.csv` + - `groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2` +- [ ] Keep command-line parsing separate from chemistry logic. + +## Phase 4: File And Path Handling + +- [ ] Replace scattered `os.path` logic with `pathlib.Path` where practical. +- [ ] Centralize SMILES file loading into one helper. +- [ ] Replace `list(open(...))` with context-managed file reads. +- [ ] Use explicit encodings for text files. +- [ ] Avoid writing implicit side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` to the current working directory unless requested. +- [ ] Make batch output paths predictable and configurable. + +## Phase 5: Exceptions, Logging, And Error Reporting + +- [ ] Replace bare `except:` blocks with specific exceptions. +- [ ] Replace `raise NotImplemented(...)` with `NotImplementedError` or `ValueError`. +- [ ] Move user-facing `print()` calls toward the CLI layer. +- [ ] Use `logging` in library code. +- [ ] For batch processing, continue processing failed molecules only when configured to do so. +- [ ] Preserve enough failure detail to debug invalid SMILES, unsupported formats, and dependency problems. + +## Phase 6: Dependency Boundaries And Lazy Imports + +- [ ] Keep core counting and property calculation independent from OpenBabel. +- [ ] Lazy-load OpenBabel only inside conversion functions that need it. +- [ ] Lazy-load visualization dependencies only inside viewer functions that need them. +- [ ] Consider optional dependency groups: + - `.[convert]` + - `.[viewer]` + - `.[dev]` +- [ ] Document that OpenBabel should usually be installed from conda-forge. + +## Phase 7: Data Loading And Performance + +- [ ] Cache `Loader` results so Excel files are not repeatedly parsed. +- [ ] Keep SMARTS/group-counting logic behaviorally unchanged until tests cover it better. +- [ ] Rename or alias `*_mpi` APIs to `*_parallel`, since they use `joblib`, not MPI. +- [ ] Preserve old method names during transition for backward compatibility. + +## Phase 8: Documentation And CI + +- [ ] Update README with: + - editable install + - conda/OpenBabel note + - library API examples + - CLI examples +- [ ] Add a build check: + - `python -m build` +- [ ] Add GitHub Actions for: + - install + - lint + - tests + - package build +- [ ] Keep generated API documentation separate from source documentation. + +## Current Known Review Findings + +- `Groupy.egg-info/`, `build/`, and `dist/` are tracked generated artifacts. +- The command-line interface is tightly coupled to `input()` prompts. +- Importing the current CLI path can require OpenBabel even for workflows that do not use conversion. +- Several modules use bare `except:` and swallow useful debugging information. +- Some file reads use `list(open(...))` without context managers or explicit encoding. +- Batch workflows write failure logs to fixed filenames in the current working directory. + +## Near-Term Next Step + +Start with Phase 1 and Phase 2: + +1. Remove generated artifacts from Git tracking. +2. Add the first `pytest` smoke tests. +3. Run editable install, compile, and tests after each small change. diff --git a/build/lib/groupy/__init__.py b/build/lib/groupy/__init__.py deleted file mode 100644 index b233fce..0000000 --- a/build/lib/groupy/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from . import gp_loader -from . import gp_tool -from . import gp_viewer -from . import gp_convertor -from . import gp_calculator -from . import gp_counter -from . import gp_generator diff --git a/build/lib/groupy/gp_3x_calculator.py b/build/lib/groupy/gp_3x_calculator.py deleted file mode 100644 index aed3992..0000000 --- a/build/lib/groupy/gp_3x_calculator.py +++ /dev/null @@ -1,288 +0,0 @@ -from rdkit import Chem -from math import log -import pandas as pd -from tqdm import tqdm -from joblib import Parallel, delayed - -from groupy.gp_loader import Loader -from groupy.gp_counter import Counter - - -class Calculator: - def __init__(self): - self.loader = Loader() - self.counter = Counter() - self.parameters_step_wise = self.loader.load_parameters(parameter_type='step_wise', split=False) - self.parameters_simultaneous = self.loader.load_parameters(parameter_type='simultaneous', split=False) - - def __repr__(self): - return '这是一个使用基团贡献法计算分子性质的计算器' - - @staticmethod - def Tm(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: # 这样写是取了字典的键 - right_side_eq += group_number[i] * parameters[i]['Tm'] - Tm = parameters[1]['Tm0'] * log(max(right_side_eq, 1.0)) # 这里的1.0是为了上计算结果小于0K的都设置为0K - return round(Tm, 3) - - @staticmethod - def Tb(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Tb'] - Tb = parameters[1]['Tb0'] * log(max(right_side_eq, 1.0)) # 这里的1.0是为了上计算结果小于0K的都设置为0K - return round(Tb, 3) - - @staticmethod - def Tc(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Tc'] - Tc = parameters[1]['Tc0'] * log(max(right_side_eq, 1.0)) # 这里的1.0是为了上计算结果小于0K的都设置为0K - return round(Tc, 3) - - @staticmethod - def Pc(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Pc'] - Pc = parameters[1]['Pc1'] + (right_side_eq + parameters[1]['Pc2']) ** -2 - return round(Pc, 4) - - @staticmethod - def Vc(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Vc'] - Vc = parameters[1]['Vc0'] + right_side_eq - return round(Vc, 2) - - @staticmethod - def delta_Gf(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Gf'] - delta_Gf = parameters[1]['Gf0'] + right_side_eq - return round(delta_Gf, 3) - - @staticmethod - def delta_Hf(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Hf'] - delta_Hf = parameters[1]['Hf0'] + right_side_eq - return round(delta_Hf, 3) - - @staticmethod - def delta_Hv(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Hv'] - delta_Hv = parameters[1]['Hv0'] + right_side_eq - return round(delta_Hv, 3) - - @staticmethod - def delta_Hfus(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Hfus'] - delta_Hfus = parameters[1]['Hfus0'] + right_side_eq - return round(delta_Hfus, 3) - - @staticmethod - def flash_point(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Fp'] - Fp = parameters[1]['Fp0'] + right_side_eq - return round(Fp, 3) - - @staticmethod - def molar_volume(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Vm'] - Vm = parameters[1]['Vm0'] + right_side_eq - return round(Vm, 3) - - @staticmethod - def density(molar_mass, Vs): - return round(molar_mass / (1000 * Vs), 3) - - @staticmethod - def delta_Hc(C_number, H_number, delta_Hf): - delta_Hc = -(-395.51 * C_number - 241.83 * H_number / 2 - delta_Hf) - return round(delta_Hc, 3) - - @staticmethod - def q(delta_Hc, molar_mass): - return round(delta_Hc / molar_mass, 3) - - @staticmethod - def isp(C_number, H_number, q): - H_C_ratio = H_number / C_number - parameter = q * (11.91 + H_C_ratio) / (43.66 + 8.936 * H_C_ratio) - if parameter < 0: # todo这是无意中发现的错误,可能是因为算的不是碳氢分子(确实目前发现的出现错误的是含F原子的分子) - parameter = 0 - isp = (2 * 0.556 * parameter) ** 0.5 / 9.8 * 1000 - return round(isp, 3) - - @staticmethod - def C_number(mol): - C_atoms = [i for i in mol.GetAtoms() if i.GetAtomicNum() == 6] - return len(C_atoms) - - @staticmethod - def H_number(mol): - H_atoms = [i for i in Chem.AddHs(mol).GetAtoms() if i.GetAtomicNum() == 1] - return len(H_atoms) - - @staticmethod - def smiles(mol): - return Chem.MolToSmiles(mol) - - @staticmethod - def molar_mass(mol): - molar_mass = 0.000 - atoms = Chem.AddHs(mol).GetAtoms() - for i in atoms: - molar_mass += i.GetMass() - return molar_mass - - def calculate_a_mol(self, mol, parameter_type='step_wise', debug=False): - init_smi = mol - try: - if isinstance(mol, str): - mol = Chem.MolFromSmiles(mol) - group_number = self.counter.count_a_mol(mol, clear_mode=True, add_note=True) - if group_number.get('note', ''): - counter_note = group_number['note'] - del group_number['note'] - else: - counter_note = '' - - if parameter_type == 'step_wise': - parameters = self.parameters_step_wise # todo 这里是速度慢的原因,每算一个分子都要加载一遍参数,要修改 - elif parameter_type == 'simultaneous': - parameters = self.parameters_simultaneous - else: - raise NotImplemented('不可用的参数类型,只能使用step_wise或simultaneous') - - if debug: - print(group_number) - # print(parameters) - Tm = self.Tm(group_number=group_number, parameters=parameters) - Tb = self.Tb(group_number=group_number, parameters=parameters) - Tc = self.Tc(group_number=group_number, parameters=parameters) - Pc = self.Pc(group_number=group_number, parameters=parameters) - Vc = self.Vc(group_number=group_number, parameters=parameters) - delta_Gf = self.delta_Gf(group_number=group_number, parameters=parameters) - delta_Hf = self.delta_Hf(group_number=group_number, parameters=parameters) - delta_Hv = self.delta_Hv(group_number=group_number, parameters=parameters) - delta_Hfus = self.delta_Hfus(group_number=group_number, parameters=parameters) - C_number = self.C_number(mol) - H_number = self.H_number(mol) - molar_mass = self.molar_mass(mol) - flash_point = self.flash_point(group_number=group_number, parameters=parameters) - molar_volume = self.molar_volume(group_number=group_number, parameters=parameters) - density = self.density(molar_mass=molar_mass, Vs=molar_volume) - delta_Hc = self.delta_Hc(C_number=C_number, H_number=H_number, delta_Hf=delta_Hf) - q = self.q(delta_Hc=delta_Hc, molar_mass=molar_mass) - isp = self.isp(C_number=C_number, H_number=H_number, q=q) - smiles = self.smiles(mol) - return {'smiles': smiles, - 'molar_mass': molar_mass, - 'flash_point/K': flash_point, - 'Tm/K': Tm, 'Tb/K': Tb, 'Tc/K': Tc, - 'Pc/bar': Pc, 'Vc/(cm3/mol)': Vc, - 'density/(g/cm3)': density, - 'delta_G/(KJ/mol)': delta_Gf, - 'delta_Hf/(KJ/mol)': delta_Hf, - 'delta_Hvap/(KJ/mol)': delta_Hv, - 'delta_Hfus/(KJ/mol)': delta_Hfus, - 'molar_volume/(cm3/mol)(default298K)': molar_volume, - 'delta_Hc/(KJ/mol)': delta_Hc, - 'mass_calorific_value_h/(MJ/kg)': q, - 'ISP': isp, - 'note': counter_note + ' at 298K'} - except: - print(f'Error! There is something wrong when calculating {init_smi}, please check it.') - return {'smiles': init_smi, - 'molar_mass': '?', - 'flash_point/K': '?', - 'Tm/K': '?', 'Tb/K': '?', 'Tc/K': '?', - 'Pc/bar': '?', 'Vc/(cm3/mol)': '?', - 'density/(g/cm3)': '?', - 'delta_G/(KJ/mol)': '?', - 'delta_Hf/(KJ/mol)': '?', - 'delta_Hvap/(KJ/mol)': '?', - 'delta_Hfus/(KJ/mol)': '?', - 'molar_volume/(cm3/mol)(default298K)': '?', - 'delta_Hc/(KJ/mol)': '?', - 'mass_calorific_value_h/(MJ/kg)': '?', - 'ISP': '?', - 'note': 'There must be something wrong with this SMILES'} - - def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.csv', parameter_type='simultaneous'): # todo 还没实现不同步拟合的参数的使用 - print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - return None - mol_number = len(smiles_iterator) - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - print('start calculating...') - properties_dict_list = [] - error_smi = [] - for i in tqdm(smiles_iterator): - try: - properties_dict_list.append(self.calculate_a_mol(i, parameter_type=parameter_type)) - except: - error_smi.append(i) - print('calculation completed!') - print('start to export result to {} ...'.format(properties_file_path)) - result = pd.DataFrame(properties_dict_list) - result.to_csv(properties_file_path, index_label='index') - with open('error.txt', 'w') as f: - for i in error_smi: - f.write(i + '\n') - print('Done!') - return result - - def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_result_mpi.csv', parameter_type='simultaneous', n_jobs=1, batch_size='auto'): - print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - return None - mol_number = len(smiles_iterator) - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - print('start calculating...') - task = [delayed(self.calculate_a_mol)(i, parameter_type=parameter_type) for i in smiles_iterator] - properties_dict_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('calculation completed!') - print('start to export result to {} ...'.format(properties_file_path)) - result = pd.DataFrame(properties_dict_list) - result.to_csv(properties_file_path, index_label='index') - print('Done!') - return result - - -# if __name__ == '__main__': -# import time -# t1 = time.time() -# c = Calculator() -# c.calculate_mols_mpi(smiles_file_path='gp_3x_test_mol/SMILES.txt', n_jobs=4) -# t2 = time.time() -# print(t2 - t1) \ No newline at end of file diff --git a/build/lib/groupy/gp_3x_convertor.py b/build/lib/groupy/gp_3x_convertor.py deleted file mode 100644 index 4d2960e..0000000 --- a/build/lib/groupy/gp_3x_convertor.py +++ /dev/null @@ -1,345 +0,0 @@ -import os -from rdkit import Chem -from rdkit.Chem import AllChem -from openbabel import pybel -import pandas as pd -from tqdm import tqdm -from joblib import Parallel, delayed - - -class Convertor: - def __init__(self): - pass - - def __repr__(self): - return ('This is a object implemented some functions that can convert SMILES to 3D chemical files,' - ' such as xyz, gro... or convert 3D chemical files to SMILES') - - @staticmethod - def load_smiles_iterator(smiles_file_path): - print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplemented('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - smiles_iterator = [i.strip() for i in smiles_iterator] - return smiles_iterator - - @staticmethod - def smi_to_xyz(smi, xyz_path=None): - mol = Chem.MolFromSmiles(smi) - if mol is None: - print(f'can not read {smi}, please check your SMILES') - return False - - mol_with_h = Chem.AddHs(mol) - AllChem.EmbedMolecule(mol_with_h, useRandomCoords=True) - atom_number = len(mol_with_h.GetAtoms()) - try: - AllChem.MMFFOptimizeMolecule(mol_with_h) - opt = Chem.MolToMolBlock(mol_with_h) - except ValueError: - mol = pybel.readstring("smi", smi) - mol.addh() - if mol.make3D() is None: - opt = mol.write("mol") - else: - print(f'Error! There is something wrong when converting {smi} to xyz file, please check it.') - return False - - # Windows 下pybel有问题 - # try: - # mol = pybel.readstring("smi", smi) - # mol.addh() - # if mol.make3D() is None: - # opt = mol.write("mol") - # else: - # return False - # except: - # AllChem.MMFFOptimizeMolecule(mol_with_h) - # opt = Chem.MolToMolBlock(mol_with_h) - - if xyz_path is None: - xyz_path = smi + '.xyz' - with open(xyz_path, 'w') as file: - file.write('{}\n'.format(atom_number)) - file.write(smi + '\n') - for index, i in enumerate(opt.splitlines()[4:]): - if len(i.split()) >= 4: - if i.split()[3].isupper(): - file.write(i.split()[3] + ' ') - file.write(i.split()[0] + ' ') - file.write(i.split()[1] + ' ') - file.write(i.split()[2] + '\n') - return True - - def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path): - smiles_iterator = self.load_smiles_iterator(smiles_file_path=smiles_file_path) - mol_number = len(smiles_iterator) - zfill_number = len(str(mol_number)) + 3 - print('reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number)) - # make xyz_root_path - if os.path.exists(xyz_root_path): - print('xyz_root_path "{}" has been detected!'.format(xyz_root_path)) - else: - print('xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path)) - os.makedirs(xyz_root_path) - # end - - succeed = [] - fail = [] - for (index, smi) in tqdm(enumerate(smiles_iterator)): - smi = smi.strip() - out_name = os.path.join(xyz_root_path, '{}.xyz'.format(str(index).zfill(zfill_number))) - generate_success_flag = self.smi_to_xyz(smi=smi, xyz_path=out_name) - - if not generate_success_flag: - fail.append(smi) - else: - succeed.append(smi) - - with open('xyz_fail.txt', 'w') as f: - for i in fail: - f.write(i + '\n') - with open('xyz_succeed.txt', 'w') as f: - for i in succeed: - f.write(i + '\n') - - if len(fail) == 0: - print('done! all .xyz files has been saved in {}'.format(xyz_root_path)) - else: - print('Warning! The following SMILES fail to generate .xyz, please check...sorry(OTZ)') - print(fail) - return None - - def batch_smi_to_xyz_mpi(self, smiles_file_path, xyz_root_path, n_jobs=1, batch_size='auto'): - smiles_iterator = self.load_smiles_iterator(smiles_file_path=smiles_file_path) - mol_number = len(smiles_iterator) - zfill_number = len(str(mol_number)) + 3 - print('reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number)) - # make xyz_root_path - if os.path.exists(xyz_root_path): - print('xyz_root_path "{}" has been detected!'.format(xyz_root_path)) - else: - print('xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path)) - os.makedirs(xyz_root_path) - # end - - task = [delayed(self.smi_to_xyz)(smi=smi, xyz_path=os.path.join(xyz_root_path, '{}.xyz'.format(str(index).zfill(zfill_number)))) for (index, smi) in enumerate(smiles_iterator)] - result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('done! all .xyz files has been saved in {}'.format(xyz_root_path)) - return result - - @staticmethod - def convert_file_type(in_format, in_path, out_format, out_path=None): - try: - mol = pybel.readfile(in_format, in_path).__next__() - # print('The SMILES of this system is :') - # print(mol.write('smi')) - - if out_path is None: - out_path = in_path.split('.') - out_path = out_path[0] + '.' + out_format - mol.write(out_format, out_path, overwrite=True) - return None - except: - print(f'Error! There is something wrong when converting {in_path}, please check it.') - return None - - def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_path=None): - if out_root_path is None: - out_root_path = in_root_path - else: - # make out_root_path - if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) - else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - out_file_names = [i.split('.')[0] + '.{}'.format(out_format) for i in in_file_names] - - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - out_file_path = [os.path.join(out_root_path, i) for i in out_file_names] - - error_in_file_path = [] - for index in tqdm(range(len(in_file_path))): - try: - self.convert_file_type(in_format=in_format, in_path=in_file_path[index], - out_format=out_format, out_path=out_file_path[index]) - except: - # print('Warning!!!') - error_in_file_path.append(in_file_path[index]) - # print('There may something wrong in {}, please check it carefully!'.format(in_file_path[index])) - - # When there is something wrong, print some warning - if len(error_in_file_path) > 0: - print('Warning!Warning!Warning!') - for i in error_in_file_path: - print('There may something wrong in {}, please check it carefully!'.format(i)) - return None - - def batch_convert_file_type_mpi(self, in_format, in_root_path, out_format, out_root_path=None, n_jobs=1, batch_size='auto'): - if out_root_path is None: - out_root_path = in_root_path - else: - # make out_root_path - if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) - else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - out_file_names = [i.split('.')[0] + '.{}'.format(out_format) for i in in_file_names] - - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - out_file_path = [os.path.join(out_root_path, i) for i in out_file_names] - - task = [delayed(self.convert_file_type)(in_format=in_format, in_path=in_file_path[index],out_format=out_format, out_path=out_file_path[index]) for index in range(len(in_file_path))] - result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - return result - - @staticmethod - def file_to_smi(file_path, format=None): - """ - {'abinit': 'ABINIT Output Format', 'acesout': 'ACES output format', 'acr': 'ACR format', - 'adfband': 'ADF Band output format', 'adfdftb': 'ADF DFTB output format', 'adfout': 'ADF output format', - 'alc': 'Alchemy format', 'aoforce': 'Turbomole AOFORCE output format', - 'arc': 'Accelrys/MSI Biosym/Insight II CAR format', 'axsf': 'XCrySDen Structure Format', - 'bgf': 'MSI BGF format', 'box': 'Dock 3.5 Box format', 'bs': 'Ball and Stick format', - 'c09out': 'Crystal 09 output format', 'c3d1': 'Chem3D Cartesian 1 format', 'c3d2': 'Chem3D Cartesian 2 format', - 'caccrt': 'Cacao Cartesian format', 'can': 'Canonical SMILES format', - 'car': 'Accelrys/MSI Biosym/Insight II CAR format', 'castep': 'CASTEP format', 'ccc': 'CCC format', - 'cdjson': 'ChemDoodle JSON', 'cdx': 'ChemDraw binary format', 'cdxml': 'ChemDraw CDXML format', - 'cif': 'Crystallographic Information File', 'ck': 'ChemKin format', 'cml': 'Chemical Markup Language', - 'cmlr': 'CML Reaction format', 'cof': 'Culgi object file format', 'CONFIG': 'DL-POLY CONFIG', - 'CONTCAR': 'VASP format', 'CONTFF': 'MDFF format', 'crk2d': 'Chemical Resource Kit diagram(2D)', - 'crk3d': 'Chemical Resource Kit 3D format', 'ct': 'ChemDraw Connection Table format', - 'cub': 'Gaussian cube format', 'cube': 'Gaussian cube format', 'dallog': 'DALTON output format', - 'dalmol': 'DALTON input format', 'dat': 'Generic Output file format', 'dmol': 'DMol3 coordinates format', - 'dx': 'OpenDX cube format for APBS', 'ent': 'Protein Data Bank format', - 'exyz': 'Extended XYZ cartesian coordinates format', 'fa': 'FASTA format', 'fasta': 'FASTA format', - 'fch': 'Gaussian formatted checkpoint file format', 'fchk': 'Gaussian formatted checkpoint file format', - 'fck': 'Gaussian formatted checkpoint file format', 'feat': 'Feature format', 'fhiaims': 'FHIaims XYZ format', - 'fract': 'Free Form Fractional format', 'fs': 'Fastsearch format', 'fsa': 'FASTA format', - 'g03': 'Gaussian Output', 'g09': 'Gaussian Output', 'g16': 'Gaussian Output', - 'g92': 'Gaussian Output', 'g94': 'Gaussian Output', 'g98': 'Gaussian Output', 'gal': 'Gaussian Output', - 'gam': 'GAMESS Output', 'gamess': 'GAMESS Output', 'gamin': 'GAMESS Input', 'gamout': 'GAMESS Output', - 'got': 'GULP format', 'gpr': 'Ghemical format', 'gro': 'GRO format', 'gukin': 'GAMESS-UK Input', - 'gukout': 'GAMESS-UK Output', 'gzmat': 'Gaussian Z-Matrix Input', 'hin': 'HyperChem HIN format', - 'HISTORY': 'DL-POLY HISTORY', 'inchi': 'InChI format', 'inp': 'GAMESS Input', 'ins': 'ShelX format', - 'jin': 'Jaguar input format', 'jout': 'Jaguar output format', 'log': 'Generic Output file format', - 'lpmd': 'LPMD format', 'mcdl': 'MCDL format', 'mcif': 'Macromolecular Crystallographic Info', - 'MDFF': 'MDFF format', 'mdl': 'MDL MOL format', 'ml2': 'Sybyl Mol2 format', - 'mmcif': 'Macromolecular Crystallographic Info', 'mmd': 'MacroModel format', 'mmod': 'MacroModel format', - 'mol': 'MDL MOL format', 'mol2': 'Sybyl Mol2 format', 'mold': 'Molden format', 'molden': 'Molden format', - 'molf': 'Molden format', 'moo': 'MOPAC Output format', 'mop': 'MOPAC Cartesian format', - 'mopcrt': 'MOPAC Cartesian format', 'mopin': 'MOPAC Internal', 'mopout': 'MOPAC Output format', - 'mpc': 'MOPAC Cartesian format', 'mpo': 'Molpro output format', 'mpqc': 'MPQC output format', - 'mrv': 'Chemical Markup Language', 'msi': 'Accelrys/MSI Cerius II MSI format', 'nwo': 'NWChem output format', - 'orca': 'ORCA output format', 'out': 'Generic Output file format', 'outmol': 'DMol3 coordinates format', - 'output': 'Generic Output file format', 'pc': 'PubChem format', 'pcjson': 'PubChem JSON', - 'pcm': 'PCModel Format', 'pdb': 'Protein Data Bank format', 'pdbqt': 'AutoDock PDBQT format', - 'png': 'PNG 2D depiction', 'pos': 'POS cartesian coordinates format', 'POSCAR': 'VASP format', - 'POSFF': 'MDFF format', 'pqr': 'PQR format', 'pqs': 'Parallel Quantum Solutions format', - 'prep': 'Amber Prep format', 'pwscf': 'PWscf format', 'qcout': 'Q-Chem output format', 'res': 'ShelX format', - 'rsmi': 'Reaction SMILES format', 'rxn': 'MDL RXN format', 'sd': 'MDL MOL format', 'sdf': 'MDL MOL format', - 'siesta': 'SIESTA format', 'smi': 'SMILES format', 'smiles': 'SMILES format', - 'smy': 'SMILES format using Smiley parser', 'sy2': 'Sybyl Mol2 format', 't41': 'ADF TAPE41 format', - 'tdd': 'Thermo format', 'text': 'Read and write raw text', 'therm': 'Thermo format', - 'tmol': 'TurboMole Coordinate format', 'txt': 'Title format', 'txyz': 'Tinker XYZ format', - 'unixyz': 'UniChem XYZ format', 'VASP': 'VASP format', 'vmol': 'ViewMol format', - 'wln': 'Wiswesser Line Notation', 'xml': 'General XML format', 'xsf': 'XCrySDen Structure Format', - 'xyz': 'XYZ cartesian coordinates format', 'yob': 'YASARA.org YOB format'} - """ - try: - atoms = next(pybel.readfile(format=format, filename=file_path)) - smi = atoms.write(format='smi').split('\t')[0] - # print(smi) - return smi - except: - print('There may something wrong in {}, please check it carefully!'.format(file_path)) - return 'There may something wrong in {}, please check it carefully!'.format(file_path) - - def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None): - if out_root_path is None: - out_root_path = in_root_path - else: - # make out_root_path - if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) - else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - error_in_file_path = [] - smi_list = [] - for index in tqdm(range(len(in_file_path))): - try: - smi_list.append(self.file_to_smi(format=in_format, file_path=in_file_path[index])) - except: - # print('Warning!!!') - error_in_file_path.append(in_file_path[index]) - # print('There may something wrong in {}, please check it carefully!'.format(in_file_path[index])) - - # When there is something wrong, print some warning - if len(error_in_file_path) > 0: - print('Warning!Warning!Warning!') - for i in error_in_file_path: - print('There may something wrong in {}, please check it carefully!'.format(i)) - - with open(os.path.join(out_root_path, 'SMILES.txt'), 'w') as f: - for i in smi_list: - f.write(i + '\n') - return smi_list - - def batch_file_to_smi_mpi(self, in_format, in_root_path, out_root_path=None, n_jobs=1, batch_size='auto'): - if out_root_path is None: - out_root_path = in_root_path - else: - # make out_root_path - if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) - else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - - task = [delayed(self.file_to_smi)(format=in_format, file_path=in_file_path[index]) for index in range(len(in_file_path))] - smi_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - - with open(os.path.join(out_root_path, 'SMILES.txt'), 'w') as f: - for i in smi_list: - f.write(i + '\n') - return smi_list - - -# if __name__ == '__main__': -# import time -# -# t1 = time.time() -# c = Convertor() -# # c.smi_to_xyz('C1CCCC1C', 'C1CCCC1C.xyz') -# # c.convert_file_type(in_format='xyz', in_path='C1CCCC1C.xyz', out_format='mol', out_path='C1CCCC1C.mol') -# # c.file_to_smi('C1CCCC1C.mol', format='mol') -# -# # c.batch_file_to_smi(in_format='mol2', in_root_path=os.path.join('gp_3x_test_mol', 'test_mol')) -# x = c.batch_file_to_smi_mpi(in_format='mol', in_root_path='./test', n_jobs=4, batch_size='auto') -# t2 = time.time() -# print(t2 - t1) \ No newline at end of file diff --git a/build/lib/groupy/gp_3x_counter.py b/build/lib/groupy/gp_3x_counter.py deleted file mode 100644 index 2d1e61c..0000000 --- a/build/lib/groupy/gp_3x_counter.py +++ /dev/null @@ -1,3380 +0,0 @@ -from tqdm import tqdm -from rdkit import Chem -import pandas as pd -from joblib import Parallel, delayed - -from groupy.gp_loader import Loader - - -# tool -def has_non_aromatic_neighbor(atom): - """判断原子周围是否有非芳香原子""" - flag = False - neighbors = atom.GetNeighbors() - for i in neighbors: - if not i.GetIsAromatic(): - flag = True - break - return flag - - -def find_ring_atoms(mol, atom_idxs): - """找出给定的原子索引元组中在环上的原子索引,并返回一个元组""" - return list((i for i in atom_idxs if mol.GetAtomWithIdx(i).IsInRing())) - - -def is_in_same_ring(mol, atom_idxs: tuple): - """判断一个元组中的原子索引是否在同一个环上""" - flag = False - all_rings = [list(i) for i in Chem.GetSymmSSSR(mol)] - # set(i).issubset(j) - for i in all_rings: - if set(atom_idxs).issubset(i): - flag = True - break - return flag - - -# f order -def f_001(mol): - """CH3""" - query = Chem.MolFromSmarts('[C;H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_002(mol): - """CH2""" - query = Chem.MolFromSmarts('[C;H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_003(mol): - """CH""" - query = Chem.MolFromSmarts('[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_004(mol): - """C""" - query = Chem.MolFromSmarts('[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_005(mol): - """CH2=CH""" - query = Chem.MolFromSmarts('[C;H2;!R]=[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_006(mol): - """CH=CH""" - query = Chem.MolFromSmarts('[C;H;!R]=[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_007(mol): - """CH2=C""" - query = Chem.MolFromSmarts('[C;H2;!R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_008(mol): - """CH=C""" - query = Chem.MolFromSmarts('[C;H1;!R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_009(mol): - """C=C""" - query = Chem.MolFromSmarts('[C;H0;!R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_010(mol): - """CH2=C=CH""" - query = Chem.MolFromSmarts('[C;H2;!R]=[C;H0;!R;D2]=[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_011(mol): - """CH2=C=C""" - query = Chem.MolFromSmarts('[C;H2;!R]=[C;H0;!R;D2]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_012(mol): - """C=C=C - 2001年的论文12号基团是CH=C=CH,对应的SMART为[C;H1;!R;D2]=[C;H0;!R;D2]=[C;H1;!R;D2] - """ - query = Chem.MolFromSmarts('[C;H0;!R]=[C;H0;!R;D2]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_013(mol): - """CH#C""" - query = Chem.MolFromSmarts('[C;H1;!R;D1]#[C;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_014(mol): - """C#C""" - query = Chem.MolFromSmarts('[C;H0;!R;D2]#[C;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_015(mol): - """aCH""" - query = Chem.MolFromSmarts('[c;H1;R1;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_016(mol): # 麻烦且慢,但是应该可靠 - """aC fused with aromatic ring""" - query = Chem.MolFromSmarts('[c;H0;R&!R1;D3]') - match_list = mol.GetSubstructMatches(query) - real_match_list = [] - for i in match_list: - atom_i = mol.GetAtomWithIdx(i[0]) - if not has_non_aromatic_neighbor(atom_i): # 判断该原子是否有链接非芳香原子是为了防止把芳环上连接非芳香环也算进去 - real_match_list.append(i) - return len(real_match_list), tuple(real_match_list) - - -def f_017(mol): # 麻烦且慢,但是应该可靠 - """aC fused with nonaromatic subring""" - query = Chem.MolFromSmarts('[c;H0;R&!R1;D3]') - match_list = mol.GetSubstructMatches(query) - real_match_list = [] - for i in match_list: - atom_i = mol.GetAtomWithIdx(i[0]) - if has_non_aromatic_neighbor(atom_i): # 判断该原子是否有链接非芳香原子是为了取出芳环上连接非芳香环的,和上一个函数刚好相反 - real_match_list.append(i) - return len(real_match_list), tuple(real_match_list) - - -def f_018(mol): - """aC except as above - 这里这样写是因为我们给出了数基团的顺序,在那里会剔除重复的,所以不用担心重复 - """ - query = Chem.MolFromSmarts('[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_019(mol): - """aN in aromatic ring""" - query = Chem.MolFromSmarts('[n;H0;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_020(mol): - """aC-CH3""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_021(mol): - """aC-CH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_022(mol): - """aC-CH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_023(mol): - """aC-C""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_024(mol): - """aC-CH=CH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D2]=[C;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_025(mol): - """aC-CH=CH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D2]=[C;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_026(mol): - """aC-C=CH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D3]=[C;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_027(mol): - """aC-C#CH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D2]#[C;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_028(mol): - """aC-C#C""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D2]#[C;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_029(mol): - """OH""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_030(mol): - """aC-OH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_031(mol): - """COOH""" - query = Chem.MolFromSmarts('[C;H0;!R;D3]([O;H1;!R;D1])=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_032(mol): - """aC-COOH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3]([O;H1;!R;D1])=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_033(mol): - """CH3CO""" - query = Chem.MolFromSmarts('[C;H3;!R;D1][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_034(mol): - """CH2CO""" - query = Chem.MolFromSmarts('[C;H2;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_035(mol): - """CHCO""" - query = Chem.MolFromSmarts('[C;H1;!R][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_036(mol): - """CCO""" - query = Chem.MolFromSmarts('[C;H0;!R][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_037(mol): - """aC-CO""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_038(mol): - """CHO""" - query = Chem.MolFromSmarts('[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_039(mol): - """aC-CHO""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_040(mol): - """CH3COO""" - query = Chem.MolFromSmarts('[C;H3;!R;D1][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_041(mol): - """CH2COO""" - query = Chem.MolFromSmarts('[C;H2;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_042(mol): - """CHCOO""" - query = Chem.MolFromSmarts('[C;H1;!R;D3][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_043(mol): - """CCOO""" - query = Chem.MolFromSmarts('[C;H0;!R;D4][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_044(mol): - """HCOO""" - query = Chem.MolFromSmarts('[C;H1;!R;D2](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_045(mol): - """aC-COO""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_046(mol): - """aC-OOCH""" - query = Chem.MolFromSmarts('[C;H1;!R;D2](=[O;H0;!R;D1])[O;H0;!R;D2][c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_047(mol): - """aC-OOC""" - query = Chem.MolFromSmarts('[C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2][c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_048(mol): - """COO except as above - 取消了是不是在环上的限制 - """ - query = Chem.MolFromSmarts('[C;H0;D3](=[O;H0;D1])[O;H0;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_049(mol): - """CH3O""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_050(mol): - """CH2O""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_051(mol): - """CH-O""" - query = Chem.MolFromSmarts('[C;H1;!R]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_052(mol): - """C-O""" - query = Chem.MolFromSmarts('[C;H0;!R]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_053(mol): - """aC-O""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_054(mol): - """CH2NH2""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_055(mol): - """CHNH2""" - query = Chem.MolFromSmarts('[C;H1;!R;D3]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_056(mol): - """CNH2""" - query = Chem.MolFromSmarts('[C;H0;!R;D4]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_057(mol): - """CH3NH""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_058(mol): - """CH2NH""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_059(mol): - """CHNH""" - query = Chem.MolFromSmarts('[C;H1;!R;D3]-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_060(mol): - """CH3N - 因为有f66(CH=N)、f67(C=N),所以这里还是要求N的度为3 - """ - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[N;H0;!R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_061(mol): - """CH2N - 因为有f66(CH=N)、f67(C=N),所以这里还是要求N的度为3 - """ - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;H0;!R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_062(mol): - """aC-NH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_063(mol): - """aC-NH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_064(mol): - """aC-N""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0;!R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_065(mol): - """NH2 except as above""" - query = Chem.MolFromSmarts('[N;H2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_066(mol): - """CH=N""" - query = Chem.MolFromSmarts('[C;H1;!R;D2]=[N;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_067(mol): - """C=N""" - query = Chem.MolFromSmarts('[C;H0;!R]=[N;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_068(mol): - """CH2CN""" - query = Chem.MolFromSmarts('[C;H2;!R;D2][C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_069(mol): - """CHCN""" - query = Chem.MolFromSmarts('[C;H1;!R;D3][C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_070(mol): - """CCN""" - query = Chem.MolFromSmarts('[C;H0;!R;D4][C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_071(mol): - """aC-CN""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_072(mol): - """CN except as above - 不可能在环上 - """ - query = Chem.MolFromSmarts('[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_073(mol): - """CH2NCO""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;H0;!R;D2]=[C;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_074(mol): - """CHNCO""" - query = Chem.MolFromSmarts('[C;H1;!R]-[N;H0;!R;D2]=[C;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_075(mol): - """CNCO""" - query = Chem.MolFromSmarts('[C;H0;!R]-[N;H0;!R;D2]=[C;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_076(mol): - """aC-NCO""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0;!R;D2]=[C;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_077(mol): - """CH2NO2""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_078(mol): - """CHNO2""" - query = Chem.MolFromSmarts('[C;H1;!R;D3]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_079(mol): - """CNO2""" - query = Chem.MolFromSmarts('[C;H0;!R;D4]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_080(mol): - """aC-NO2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_081(mol): - """NO2 except as above""" - query = Chem.MolFromSmarts('[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_082(mol): - """ONO""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][N;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_083(mol): - """ONO2""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][N;+]([O;-1])=O') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_084(mol): - """HCON(CH2)2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H1;!R;D2][N;H0;!R;D3]([C;H2;!R;D2])[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_085(mol): - """HCONH(CH2)""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H1;!R;D2][N;H1;!R;D2][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_086(mol): - """CONH2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_087(mol): - """CONHCH3""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H1;!R;D2][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_088(mol): - """CONHCH2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H1;!R;D2][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_089(mol): - """CON(CH3)2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H0;!R;D3]([C;H3;!R;D1])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_090(mol): - """CONCH3CH2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H0;!R;D3]([C;H2;!R;D2])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_091(mol): - """CON(CH2)2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H0;!R;D3]([C;H2;!R;D2])[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_092(mol): - """CONHCO""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H1;!R;D2][C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_093(mol): - """CONCO""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H0;!R;D3][C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_094(mol): - """aC-CONH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_095(mol): - """aC-NH(CO)H""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_096(mol): - """aC-N(CO)H""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0;!R;D3]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_097(mol): - """aC-CONH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])[N;H1;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_098(mol): - """aC-NHCO""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_099(mol): - """aC-NCO""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0;!R;D3]-[C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_100(mol): - """NHCONH""" - query = Chem.MolFromSmarts('[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_101(mol): - """NH2CONH""" - query = Chem.MolFromSmarts('[N;H2;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_102(mol): - """NH2CON""" - query = Chem.MolFromSmarts('[N;H2;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_103(mol): - """NHCON""" - query = Chem.MolFromSmarts('[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_104(mol): - """NCON""" - query = Chem.MolFromSmarts('[N;H0;!R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_105(mol): - """aC-NHCONH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_106(mol): - """aC-NHCONH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_107(mol): - """NHCO except as above""" - query = Chem.MolFromSmarts('[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_108(mol): - """CH2Cl""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_109(mol): - """CHCl""" - query = Chem.MolFromSmarts('[C;H1;!R][Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_110(mol): - """CCl""" - query = Chem.MolFromSmarts('[C;H0;!R][Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_111(mol): - """CHCl2""" - query = Chem.MolFromSmarts('[C;H1;!R;D3](-[Cl])-[Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_112(mol): - """CCl2""" - query = Chem.MolFromSmarts('[C;H0;!R](-[Cl])-[Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_113(mol): - """CCl3""" - query = Chem.MolFromSmarts('[C;H0;!R;D4](-[Cl])(-[Cl])-[Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_114(mol): - """CH2F""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_115(mol): - """CHF""" - query = Chem.MolFromSmarts('[C;H1;!R][F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_116(mol): - """CF""" - query = Chem.MolFromSmarts('[C;H0;!R][F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_117(mol): - """CHF2""" - query = Chem.MolFromSmarts('[C;H1;!R;D3](-[F])-[F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_118(mol): - """CF2""" - query = Chem.MolFromSmarts('[C;H0;!R](-[F])-[F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_119(mol): - """CF3""" - query = Chem.MolFromSmarts('[C;H0;!R;D4](-[F])(-[F])-[F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_120(mol): - """CCl2F""" - query = Chem.MolFromSmarts('F[C;H0;!R;D4](Cl)Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_121(mol): - """HCClF""" - query = Chem.MolFromSmarts('F[C;H1;!R;D3]Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_122(mol): - """CClF2""" - query = Chem.MolFromSmarts('Cl[C;H0;!R;D4](F)F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_123(mol): - """aC-Cl""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_124(mol): - """aC-F""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_125(mol): - """aC-I""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-I') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_126(mol): - """aC-Br""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-Br') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_127(mol): - """I except as above""" - query = Chem.MolFromSmarts('I') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_128(mol): - """Br except as above""" - query = Chem.MolFromSmarts('Br') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_129(mol): - """F except as above""" - query = Chem.MolFromSmarts('F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_130(mol): - """Cl except as above""" - query = Chem.MolFromSmarts('Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_131(mol): - """CHNOH""" - query = Chem.MolFromSmarts('[C;H1;!R;D2]=[N;H0;!R;D2]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_132(mol): - """CNOH""" - query = Chem.MolFromSmarts('[C;H0;!R]=[N;H0;!R;D2]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_133(mol): - """aC-CHNOH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D2]=[N;H0;!R;D2]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_134(mol): - """OCH2CH2OH""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][C;H2;!R;D2][C;H2;!R;D2][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_135(mol): - """OCHCH2OH""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][C;H1;!R;D3][C;H2;!R;D2][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_136(mol): - """OCH2CHOH""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][C;H2;!R;D2][C;H1;!R;D3][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_137(mol): - """O-OH""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_138(mol): - """CH2SH""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_139(mol): - """CHSH - 这里要求C是D3,是因为后面又142(-SH(except as above)) - """ - query = Chem.MolFromSmarts('[C;H1;!R;D3]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_140(mol): - """CSH - 这里要求C是D4,是因为后面又142(-SH(except as above)) - """ - query = Chem.MolFromSmarts('[C;H0;!R;D4]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_141(mol): - """aC-SH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_142(mol): - """SH except as above""" - query = Chem.MolFromSmarts('[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_143(mol): - """CH3S""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_144(mol): - """CH2S""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_145(mol): - """CHS""" - query = Chem.MolFromSmarts('[C;H1;!R]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_146(mol): - """CS""" - query = Chem.MolFromSmarts('[C;H0;!R]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_147(mol): - """aC-S-""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_148(mol): - """SO""" - query = Chem.MolFromSmarts('[S;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_149(mol): - """SO2""" - query = Chem.MolFromSmarts('[S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_150(mol): - """SO3(sulfite)""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][S;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_151(mol): - """SO3(sulfonate)""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_152(mol): - """SO4(sulfite)""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_153(mol): - """aC-SO""" - query = Chem.MolFromSmarts('[c;H0;R;D3][S;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_154(mol): - """aC-SO2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -# todo 含磷化合物太复杂了,以后再检查吧 -def f_155(mol): - """PH(phosphine)""" - query = Chem.MolFromSmarts('[P;H1;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_156(mol): - """P(phosphine)""" - query = Chem.MolFromSmarts('[P;H0;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_157(mol): - """PO3(phosphine)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H0;D3]([O;D2;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_158(mol): - """PHO3(phosphonate)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H1;D3](=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_159(mol): - """PO3(phosphonate)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H0;D4](=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_160(mol): - """PHO4(phosphate)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H0;D4]([O;H1;D1])(=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_161(mol): - """PO4(phosphate)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H0;D4]([O;H0;D2])(=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_162(mol): - """aC-PO4""" - query = Chem.MolFromSmarts('[c;H0;R][O;D2;H0][P;H0;D4]([O;H0;D2])(=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_163(mol): - """aC-P""" - query = Chem.MolFromSmarts('[c;H0;R][P;H0;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_164(mol): - """CO3(carbonate)""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_165(mol): - """C2H3O""" - query = Chem.MolFromSmarts('[C;H2;R;D2]1[O;H0;R;D2][C;H1;R;D3]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_166(mol): - """C2H2O""" - query = Chem.MolFromSmarts('[C;H2;R;D2]1[O;H0;R;D2][C;H0;R]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_167(mol): - """C2O""" - query = Chem.MolFromSmarts('[C;H1;R][O;H0;R][C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_168(mol): - """CH2(cyclic)""" - query = Chem.MolFromSmarts('[C;H2;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_169(mol): - """CH(cyclic)""" - query = Chem.MolFromSmarts('[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_170(mol): - """C(cyclic)""" - query = Chem.MolFromSmarts('[C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_171(mol): - """CH=CH(cyclic)""" - query = Chem.MolFromSmarts('[C;H1;D2]=[C;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_172(mol): - """CH=C(cyclic)""" - query = Chem.MolFromSmarts('[C;H1;D2]=[C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_173(mol): - """C=C(cyclic)""" - query = Chem.MolFromSmarts('[C;H0]=[C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_174(mol): - """CH2=C(cyclic)""" - query = Chem.MolFromSmarts('[C;H2;D1]=[C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_175(mol): - """NH(cyclic)""" - query = Chem.MolFromSmarts('[N;H1;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_176(mol): - """N(cyclic)""" - query = Chem.MolFromSmarts('[N;H0;D3;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_177(mol): # todo 芳香性 - """CH=N(cyclic)""" - # '[C,c;H1]=,:[N,n;H0;D2;R]' - query1 = Chem.MolFromSmarts('[C;H1]=[N;H0;D2;R]') - match_list = mol.GetSubstructMatches(query1) - return len(match_list), match_list - - -def f_178(mol): # todo 芳香性 - """C=N(cyclic)""" - query = Chem.MolFromSmarts('[C;H0]=[N;H0;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_179(mol): - """O(cyclic)""" - query = Chem.MolFromSmarts('[O;H0;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_180(mol): - """CO(cyclic)""" - query = Chem.MolFromSmarts('[C;H0;R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_181(mol): # todo 芳香性 - """S(cyclic)""" - query = Chem.MolFromSmarts('[S,s;H0;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_182(mol): # todo 芳香性 - """SO2(cyclic)""" - query = Chem.MolFromSmarts('[O;H0;D1;!R]=[S,s;H0;D4;R]=[O;H0;D1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -# todo 183-220都没有官方的例子 -def f_183(mol): - """>NH""" - query = Chem.MolFromSmarts('[N;H1;D3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_184(mol): - """-O-""" - query = Chem.MolFromSmarts('[O;H0;D2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_185(mol): - """-S-""" - query = Chem.MolFromSmarts('[S;H0;D2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_186(mol): - """>CO""" - query = Chem.MolFromSmarts('[C;H0;D3;!R]=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_187(mol): - """PO2""" - query = Chem.MolFromSmarts('[O;H0;!R][P;!R][O;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_188(mol): - """CH-N""" - query = Chem.MolFromSmarts('[C;H1;!R]-[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_189(mol): - """SiHO""" - query = Chem.MolFromSmarts('[Si;H1;!R]=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_190(mol): - """SiO""" - query = Chem.MolFromSmarts('[Si;H0;!R]=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_191(mol): - """SiH2""" - query = Chem.MolFromSmarts('[Si;H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_192(mol): - """SiH1""" - query = Chem.MolFromSmarts('[Si;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_193(mol): - """Si""" - query = Chem.MolFromSmarts('[Si;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_194(mol): - """(CH3)3N""" - query = Chem.MolFromSmarts('[C;H3][N;!R]([C;H3])[C;H3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_195(mol): - """N=N""" - query = Chem.MolFromSmarts('[N;H0;!R]=[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_196(mol): - """Ccyc=N-""" - query = Chem.MolFromSmarts('[C;H0;R]=[N;H0;D2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_197(mol): - """Ccyc=CH-""" - query = Chem.MolFromSmarts('[C;H0;R]=[C;H1;D2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_198(mol): - """Ccyc=NH""" - query = Chem.MolFromSmarts('[C;H0;R]=[N;H1;D1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_199(mol): - """N=O""" - query = Chem.MolFromSmarts('[N;H0;!R]=[O;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_200(mol): - """Ccyc=C""" - query = Chem.MolFromSmarts('[C;H0;R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_201(mol): - """P=O""" - query = Chem.MolFromSmarts('[P;H0;!R]=[O;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_202(mol): - """N=N""" - query = Chem.MolFromSmarts('[N;H0;!R]=[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_203(mol): - """C=NH""" - query = Chem.MolFromSmarts('[C;H0;!R]=[N;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_204(mol): - """>C=S""" - query = Chem.MolFromSmarts('[C;H0;!R;D3]=[S;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_205(mol): - """aC-CON""" - query = Chem.MolFromSmarts('[c]!@&-[C](=[O;H0;D1])[N;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_206(mol): - """aC=O""" - query = Chem.MolFromSmarts('[c]!@&=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_207(mol): - """aN-""" - query = Chem.MolFromSmarts('[n;H0;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_208(mol): - """-Na""" - query = Chem.MolFromSmarts('[Na]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_209(mol): - """-K""" - query = Chem.MolFromSmarts('[K]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_210(mol): - """HCONH""" - query = Chem.MolFromSmarts('[C;H1;!R](=[O;H0;D1])[N;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_211(mol): - """CHOCH""" - query = Chem.MolFromSmarts('[C;H1;!R](=[O;H0;D1])[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_212(mol): - """C2O""" - query = Chem.MolFromSmarts('[C;H0;R]1[O;H0;R][C;H0;R]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_213(mol): - """SiH3""" - query = Chem.MolFromSmarts('[Si;H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_214(mol): # todo ? - """SiH2O""" - query = Chem.MolFromSmarts('[Si;H2;!R][O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_215(mol): - """CH=C=CH""" - query = Chem.MolFromSmarts('[C;H1;!R]=[C;H0;!R]=[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_216(mol): - """CH=C=C""" - query = Chem.MolFromSmarts('[C;H1;!R]=[C;H0;!R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_217(mol): - """OP(=S)O""" - query = Chem.MolFromSmarts('[O;H0][P;H0](=[S;H0])[O;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_218(mol): # todo ? - """R""" - query = Chem.MolFromSmarts('[*]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_219(mol): - """CF2cyc""" - query = Chem.MolFromSmarts('[F][C;H0;R][F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_220(mol): - """CFcyc""" - query = Chem.MolFromSmarts('[F][C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -# s order -def s_001(mol): - """(CH3)2CH""" - query = Chem.MolFromSmarts('[C;H3;!R;D1][C;H1;!R;D3][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_002(mol): - """(CH3)3C""" - query = Chem.MolFromSmarts('[C;H3;!R;D1][C;H0;!R;D4]([C;H3;!R;D1])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_003(mol): # todo 能不能把多余的原子去掉? 好像没有必要 - """CH(CH3)CH(CH3)""" - query = Chem.MolFromSmarts('[*]-[C;H1;!R;D3]([C;H3;!R;D1])[C;H1;!R;D3]([C;H3;!R;D1])-[*]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_004(mol): # todo 能不能把多余的原子去掉 - """CH(CH3)C(CH3)2""" - query = Chem.MolFromSmarts('[*]-[C;H1;!R;D3]([C;H3;!R;D1])[C;H0;!R;D4]([*])([C;H3;!R;D1])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_005(mol): # todo 能不能把多余的原子去掉 - """C(CH3)2C(CH3)2""" - query = Chem.MolFromSmarts('[*]-[C;H0;!R;D4]([C;H3;!R;D1])([C;H3;!R;D1])[C;H0;!R;D4]([*])([C;H3;!R;D1])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_006(mol): - """CHn=CHm-CHp=CHk k,m,n,p=0,1,2""" - query = Chem.MolFromSmarts('[C;!R]=[C;!R]-[C;!R]=[C;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_007(mol): - """CH3-CHm=CHn m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;!R]=[C;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_008(mol): - """CH2-CHm=CHn m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[C;!R]=[C;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_009(mol): - """CHp-CHm=CHn p=0,1 m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;!H2;!R]-[C;!R]=[C;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_010(mol): - """CHCHO or CCHO""" - query1 = Chem.MolFromSmarts('[C;H1;!R][C;H1;!R;D2]=[O;H0;D1]') - query2 = Chem.MolFromSmarts('[C;H0;!R][C;H1;!R;D2]=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query1) + mol.GetSubstructMatches(query2) - return len(match_list), match_list - - -def s_011(mol): - """CH3COCH2""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_012(mol): - """CH3COCH or CH3COC""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H1,H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_013(mol): - """CHCOOH or CCOOH""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H1,H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_014(mol): - """CH3COOCH or CH3COOC""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H0;!R;D2]-[C;H1,H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_015(mol): - """CO-O-CO""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3]-[O;H0;!R;D2]-[C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_016(mol): - """CHOH""" - query = Chem.MolFromSmarts('[C;H1;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_017(mol): - """COH""" - query = Chem.MolFromSmarts('[C;H0;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_018(mol): - """CH3COCHnOH n=0,1,2""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])[C;H0,H1,H2;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_019(mol): - """NCCHOH or NCCOH""" - query = Chem.MolFromSmarts('[N;H0;!R;D1]#[C;H0;!R;D2][C;H0,H1;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_020(mol): - """OH-CHn-COO n=0,1,2""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]-[C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_021(mol): - """CHm(OH)CHn(OH) m,n=0,1,2""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]-[C;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_022(mol): - """CHm(OH)CHn(NHp) m,n,p=0,1,2""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]-[C;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[N;H0,H1,H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_023(mol): - """CHm(NH2)CHn(NH2) m,n=0,1,2""" - query = Chem.MolFromSmarts('[N;H2;!R;D1]-[C;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_024(mol): - """CHm(NH)CHn(NH2) m,n=0,1,2""" - query = Chem.MolFromSmarts('[N;H;!R;D2]-[C;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_025(mol): - """H2NCOCHnCHmCONH2 m,n=0,1,2""" - query = Chem.MolFromSmarts( - '[N;H2;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H0,H1,H2;!R][C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_026(mol): - """CHm(NHn)-COOH m,n=0,1,2""" - query = Chem.MolFromSmarts('[N;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_027(mol): - """HOOC-CHn-COOH n=1,2""" - query = Chem.MolFromSmarts( - '[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_028(mol): - """HOOC-CHn-CHm-COOH m,n=1,2""" - query = Chem.MolFromSmarts( - '[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_029(mol): - """HO-CHn-COOH n=0,1,2""" - query = Chem.MolFromSmarts('[O;H1;!R;D1][C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_030(mol): - """NH2-CHn-CHm-COOH m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([N;H2;!R;D1])-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_031(mol): - """CH3-O-CHn-COOH n=1,2""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[O;H0;!R;D2]-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_032(mol): - """HS-CH-COOH""" - query = Chem.MolFromSmarts('[S;H1;!R;D1][C;H1;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_033(mol): - """HS-CHn-CHm-COOH m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([S;H1;!R;D1])-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_034(mol): - """NC-CHn-CHm-CN m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([C;H0;!R;D2]#[N;H0;!R;D1])-[C;H1,H2;!R]([C;H0;!R;D2]#[N;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_035(mol): - """OH-CHn-CHm-CN m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([O;H1;!R;D1])-[C;H1,H2;!R]([C;H0;!R;D2]#[N;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_036(mol): - """HS-CHn-CHm-SH m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([S;H1;!R;D1])-[C;H1,H2;!R]([S;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_037(mol): - """COO-CHn-CHm-OOC m,n=1,2""" - query = Chem.MolFromSmarts( - '[C;H1,H2;!R]([O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1]))-[C;H1,H2;!R]([O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_038(mol): - """OOC-CHn-CHm-COO m,n=1,2""" - query = Chem.MolFromSmarts( - '[C;H1,H2;!R]([C;H0;D3;!R](=[O;H0;!R;D1])[O;H0;!R;D2])-[C;H1,H2;!R]([C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_039(mol): - """NC-CHn-COO n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([C;H0;!R;D2]#[N;H0;!R;D1])([C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_040(mol): - """COCHnCOO n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([C;H0;!R;D3](=[O;H0;!R;D1]))([C;H0;D3;!R](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_041(mol): - """CHm-O-CHn=CHp m,n,p=0,1,2,3""" - query = Chem.MolFromSmarts('[C;H0,H1,H2,H3;!R]-[O;H0;!R;D2]-[C;H0,H1,H2,H3;!R]=[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_042(mol): - """CHm=CHn-F m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_043(mol): - """CHm=CHn-Br m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-Br') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_044(mol): - """CHm=CHn-I m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-I') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_045(mol): - """CHm=CHn-Cl m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_046(mol): - """CHm=CHn-CN m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_047(mol): - """CHm=CHn-COO-CHp m,n,p=0,1,2,3""" - query = Chem.MolFromSmarts( - '[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-[C;H0;!R](=[O;H0;D1;!R])[O;H0;!R;D2]-[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_048(mol): - """CHm=CHn-CHO m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_049(mol): - """CHm=CHn-COOH m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_050(mol): - """aC-CHn-X n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[F,Cl,Br,I]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_051(mol): - """aC-CHn-NHm n=1,2 m=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[N;H0,H1,H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_052(mol): - """aC-CHn-O- n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_053(mol): - """aC-CHn-OH n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_054(mol): - """aC-CHn-CN n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_055(mol): - """aC-CHn-CHO n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_056(mol): - """aC-CHn-SH n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_057(mol): - """aC-CHn-COOH n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_058(mol): - """aC-CHn-CO- n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_059(mol): - """aC-CHn-S- n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_060(mol): - """aC-CHn-OOC-H n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]([O;H0;!R;D2][C;H1;!R;D2](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_061(mol): - """aC-CHn-NO2 n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_062(mol): - """aC-CHn-CONH2 n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_063(mol): - """aC-CHn-OOC n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]([O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_064(mol): - """aC-CHn-COO n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]([C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_065(mol): - """aC-SO2-OH n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_066(mol): - """aC-CH(CH3)2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D3]([C;H3;!R;D1])([C;H3;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_067(mol): - """aC-C(CH3)3""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D4]([C;H3;!R;D1])([C;H3;!R;D1])([C;H3;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_068(mol): - """aC-CF3""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D4](F)(F)F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_069(mol): # todo 芳香性 - """(CHn=C)(cyclic)-CHO n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_070(mol): # todo 芳香性 - """(CHn=C)(cyclic)-COO-CHm m,n=0,1,2,3""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H0;!R;D2]-[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_071(mol): # todo 芳香性 - """(CHn=C)(cyclic)-CO- n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_072(mol): # todo 芳香性 - """(CHn=C)cyc-CH3, n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_073(mol): # todo 芳香性 - """(CHn=C)cyc-CH2, n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_074(mol): # todo 芳香性 - """(CHn=C)(cyclic)-CN n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_075(mol): # todo 芳香性 - """(CHn=C)(cyclic)-Cl n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_076(mol): - """CHcyc-CH3""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_077(mol): - """CHcyc-CH2""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_078(mol): - """CHcyc-CH""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_079(mol): - """CHcyc-C""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_080(mol): - """CHcyc-CH=CHn n=1,2""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H1;!R;D2]=[C;H1,H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_081(mol): - """CHcyc-C=CHn n=1,2""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H0;!R;D3]=[C;H1,H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_082(mol): - """CHcyc-Cl""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_083(mol): - """CHcyc-F""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_084(mol): - """CHcyc-OH""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_085(mol): - """CHcyc-NH2""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_086(mol): - """CHcyc-NH-CHn n=0,1,2,3""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[N;H1;!R;D2]-[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_087(mol): - """CHcyc-N-CHn n=0,1,2,3""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[N;H0;!R;D3]-[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_088(mol): - """CHcyc-SH""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_089(mol): - """CHcyc-CN""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_090(mol): - """CHcyc-COOH""" - query = Chem.MolFromSmarts('[C;H1;R;D3](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_091(mol): - """CHcyc-CO-""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_092(mol): - """CHcyc-NO2""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_093(mol): - """CHcyc-S-""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_094(mol): - """CHcyc-CHO""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_095(mol): - """CHcyc-O-""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_096(mol): - """CHcyc-OOCH""" - query = Chem.MolFromSmarts('[C;H1;R;D3]([O;H0;!R;D2][C;H1;!R;D2](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_097(mol): - """CHcyc-COO""" - query = Chem.MolFromSmarts('[C;H1;R;D3]([C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_098(mol): - """CHcyc-OOC""" - query = Chem.MolFromSmarts('[C;H1;R;D3]([O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_099(mol): - """Ccyc-CH3 - 环上的C要求D4是因为前面有s72 - """ - query = Chem.MolFromSmarts('[C;H0;R;D4][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_100(mol): - """Ccyc-CH2 - 环上的C要求D4是因为前面有s73 - """ - query = Chem.MolFromSmarts('[C;H0;R;D4][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_101(mol): - """Ccyc-OH""" - query = Chem.MolFromSmarts('[C;H0;R;D4][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_102(mol): - """>Ncyc-CH3""" - query = Chem.MolFromSmarts('[N,n;H0;D3;R]-[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_103(mol): - """>Ncyc-CH2""" - query = Chem.MolFromSmarts('[N,n;H0;D3;R]-[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_104(mol): - """AROMRINGs1s2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_105(mol): - """AROMRINGs1s3""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_106(mol): - """AROMRINGs1s4""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_107(mol): - """AROMRINGs1s2s3""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_108(mol): - """AROMRINGs1s2s4""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_109(mol): - """AROMRINGs1s3s5""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_110(mol): - """AROMRINGs1s2s3s4""" - query = Chem.MolFromSmarts( - '[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_111(mol): - """AROMRINGs1s2s3s5""" - query = Chem.MolFromSmarts( - '[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_112(mol): - """AROMRINGs1s2s4s5""" - query = Chem.MolFromSmarts( - '[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_113(mol): - """PYRIDINEs2""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_114(mol): - """PYRIDINEs3""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_115(mol): - """PYRIDINEs4""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_116(mol): - """PYRIDINEs2s3""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_117(mol): - """PYRIDINEs2s4""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_118(mol): - """PYRIDINEs2s5""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_119(mol): - """PYRIDINEs2s6""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_120(mol): - """PYRIDINEs3s4""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_121(mol): - """PYRIDINEs3s5""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_122(mol): - """PYRIDINEs2s3s6""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_123(mol): - """(CHn=CHm)cyc-COOH""" - query = Chem.MolFromSmarts('[C;R]@&=[C;R]!@&-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_124(mol): - """AROMRINGs1s2s3s4s5""" - # query = Chem.MolFromSmarts('[c]1[c](!@&-[*])[c](!@&-[*])[c](!@&-[*])[c](!@&-[*])[c]1(!@&-[*])') - query = Chem.MolFromSmarts( - '[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_125(mol): - """aC-NHCOCH2N""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[N;H1]-[C;H0](=[O;H0;D1])[C;H2][N]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_126(mol): - """(N=C)cyc-CH3""" - query = Chem.MolFromSmarts('[N;R]@&=[C;R]!@&-[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_127(mol): - """aC-CONH(CH2)2N""" - query = Chem.MolFromSmarts('[c]!@&-[C](=[O;H0;D1])[N;H1][C;H2][C;H2][N]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_128(mol): # todo 原文128、129、130完全一样,不知道怎么回事 - """aC-SO2NHn n=0,1,2""" - query = Chem.MolFromSmarts('[c]!@&-[S](=[O;H0;D1])(=[O;H0;D1])[N;H0,H1,H2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_129(mol): # todo 原文128、129、130完全一样,不知道怎么回事 - """aC-SO2NHn n=0,1,2""" - query = Chem.MolFromSmarts('[c]!@&-[S](=[O;H0;D1])(=[O;H0;D1])[N;H0,H1,H2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_130(mol): # todo 原文128、129、130完全一样,不知道怎么回事 - """aC-SO2NHn n=0,1,2""" - query = Chem.MolFromSmarts('[c]!@&-[S](=[O;H0;D1])(=[O;H0;D1])[N;H0,H1,H2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -# t order -def t_001(mol): - """HOOC-(CHn)m-COOH m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[C;H0;!R;D3](=[O;H0;!R;D1])(-[O;H1;!R;D1]){}-[C;H0;!R;D3](=[O;H0;!R;D1])(-[O;H1;!R;D1])'.format( - '-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_002(mol): - """NHn-(CHn)m-COOH m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[N;H0,H1,H2;!R]{}-[C;H0;!R;D3](=[O;H0;!R;D1])(-[O;H1;!R;D1])'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_003(mol): - """NH2-(CHn)m-OH m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[N;H2;!R;D1]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_004(mol): - """OH-(CHn)m-OH m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[O;H1;!R;D1]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_005(mol): - """OH-(CHp)k-O-(CHn)m-OH m,k>0;p,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - for j in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[O;H1;!R;D1]{}-[O;H0;D2;!R]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i, '-[C;H0,H1,H2;!R]' * j)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_006(mol): - """OH-(CHp)k-S-(CHn)m-OH m,k>0;p,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - for j in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[O;H1;!R;D1]{}-[S;H0;D2;!R]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i, '-[C;H0,H1,H2;!R]' * j)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_007(mol): - """OH-(CHp)k-NHx-(CHn)m-OH m,k>0;x,p,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - for j in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[O;H1;!R;D1]{}-[N;H0,H1,H2;!R]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i, '-[C;H0,H1,H2;!R]' * j)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_008(mol): - """CHp-O-(CHn)m-OH m>2;p,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R][O;H0;!R;D2]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_009(mol): - """NH2-(CHn)m-NH2 m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[N;H2;!R;D1]{}-[N;H2;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_010(mol): - """NHk-(CHn)m-NH2 m>2;n=0,1,2;k=0,1""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[N;H0,H1;!R]{}-[N;H2;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_011(mol): - """SH-(CHn)m-SH m>2;n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[S;H1;!R;D1]{}-[S;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_012(mol): - """CN-(CHn)m-CN m>2;n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[C;H0;!R;D2](#[N;H0;!R;D1]){}-[C;H0;!R;D2](#[N;H0;!R;D1])'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_013(mol): - """COO-(CHn)m-OOC m>2;n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H0;!R;D2]{}-[O;H0;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])'.format( - '-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_014(mol): - """aC-(CHn=CHm)cyc (fused rings) m,n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@&-[C;H1,H2;R]@&=[C;H1,H2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_015(mol): - """aC-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]!@&-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_016(mol): - """aC-CHncyc (different rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]!@&-[C;H0,H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_017(mol): - """aC-CHncyc (fused rings) n=0,1,2原文写的0,1应该是不对的""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@&-[C;H0,H1,H2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_018(mol): - """aC-(CHn)m-aC m>1 n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(2, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[c;H0;R;D3]{}-[c;H0;R;D3]'.format('-[C;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_019(mol): - """aC-(CHn)m-CHcyc m>1 n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[c;H0;R;D3]{}-[C;H1;R;D3]'.format('-[C;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_020(mol): - """CHcyc-CHcyc (different rings)""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-&!@[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_021(mol): - """CHcyc-(CHn)m-CHcyc (different rings) m>0, n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[C;H1;R;D3]{}!@&-[C;H1;R;D3]'.format('!@&-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_022(mol): - """CH multiring""" - query = Chem.MolFromSmarts('[C;H1;!R1&R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_023(mol): - """C multiring""" - query = Chem.MolFromSmarts('[C;H0;!R1&R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_024(mol): - """aC-CHm-aC (different rings) m=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R1]-[C;H0,H1,H2;!R]-[c;H0;R1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_025(mol): - """aC-(CHm=CHn)-aC (different rings) m,n=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]-[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_026(mol): # todo 芳香性 - """(CHm=C)cyc-CH=CH-(C=CHn)cyc (different rings) m,n没有限制""" - query = Chem.MolFromSmarts('[C;R]=[C;H0;R]-[C;H1;!R]=[C;H1;!R]-[C;H0;R]=[C;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_027(mol): # todo 芳香性 - """(CHm=C)cyc-CHp-(C=CHn)cyc (different rings) m,n,p没有限制""" - query = Chem.MolFromSmarts('[C;R]=[C;H0;R]-[C;!R]-[C;H0;R]=[C;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_028(mol): - """aC-CO-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_029(mol): - """aC-CHm-CO-aC (different rings) m=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_030(mol): # todo 芳香性 - """aC-CO-(C=CHn)cyc (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H0;R;D3]=[C;H0,H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_031(mol): - """aC-CO-CO-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H0;!R;D3](=[O;H0;!R;D1])-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_032(mol): - """aC-COcyc (fused rings)""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@&-[C;R;H0;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_033(mol): - """aC-CO-(CHn)m-CO-aC (different rings) m>0;n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1]){}-[C;H0;!R;D3](=[O;H0;!R;D1])-[c;H0;R;D3]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_034(mol): - """aC-CO-CHn,cyc (different rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H0,H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_035(mol): - """aC-CO-NHn-aC (different rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0,H1;!R]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_036(mol): - """aC-NHnCONHm-aC (different rings) m,n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0,H1;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0,H1;!R]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_037(mol): - """aC-CO-Ncyc (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[C;H0;!R;D3](=[O;H0;!R;D1])!@&-[N;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_038(mol): - """aC-Scyc (fused rings)""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@[S,s;H0;D2;R1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_039(mol): - """aC-S-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]!@&-[S;H0;D2;!R]!@&-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_040(mol): # todo ? - """aC-POn-aC (different rings) n=0,1,2,3,4""" - query0 = Chem.MolFromSmarts('[c;H0;R1;D3]-[P;H0,H1;!R]-[c;H0;R1;D3]') - query1 = Chem.MolFromSmarts('[c;H0;R1;D3]-[O;H0;D2;!R]-[P;!R]-[O;H0;D2;!R]-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query0) + mol.GetSubstructMatches(query1) - return len(match_list), match_list - - -def t_041(mol): - """aC-SOn-aC (different rings) n=1,2,3,4""" - query1 = Chem.MolFromSmarts('[c;H0;R1;D3]-[S;H0;D3;!R](=[O;!R;H0;D1])-[c;H0;R1;D3]') - query2 = Chem.MolFromSmarts('[c;H0;R1;D3]-[S;H0;D4;!R](=[O;!R;H0;D1])(=[O;!R;H0;D1])-[c;H0;R1;D3]') - query3 = Chem.MolFromSmarts('[c;H0;R1;D3]-[O;H0;D2;!R]-[S;H0;D3;!R](=[O;!R;H0;D1])-[O;H0;D2;!R]-[c;H0;R1;D3]') - query4 = Chem.MolFromSmarts( - '[c;H0;R1;D3]-[O;H0;D2;!R]-[S;H0;D4;!R](=[O;!R;H0;D1])(=[O;!R;H0;D1])-[O;H0;D2;!R]-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query1) + mol.GetSubstructMatches(query2) + mol.GetSubstructMatches( - query3) + mol.GetSubstructMatches(query4) - return len(match_list), match_list - - -def t_042(mol): - """aC-NHncyc (fused rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@[N,n;H0,H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_043(mol): - """aC-NH-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_044(mol): - """aC-(C=N)cyc (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[c,C;H0;R;D3]=,:[n,N;H0;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_045(mol): - """aC-(N=CHn)cyc (fused rings) n=0,1""" - # query = Chem.MolFromSmarts('[c][c;H0;R2]([c])-[N;H0;D2;R1]=[C;H0,H1;R]') - query = Chem.MolFromSmarts('[c;H0;R;D3]@[n,N;H0;R;D2]@,=,:[c,C;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_046(mol): - """aC-(N=CHn)cyc (fused rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@,=,:[c,C;H0,H1;R]@,=,:[n,N;H0;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_047(mol): - """aC-O-CHn-aC (different rings) n=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@,-[O;H0;!R;D2]!@,-[C;H0,H1,H2;!R]!@,-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_048(mol): - """aC-O-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[O;H0;!R;D2]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_049(mol): - """aC-CHn-O-CHm-aC (different rings) m,n=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0,H1,H2;!R]-[O;H0;!R;D2]-[C;H0,H1,H2;!R]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_050(mol): - """aC-Ocyc (fused rings)""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@[O,o;H0;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_051(mol): - """AROMFUSED[2]""" - query = Chem.MolFromSmarts('[c]:[c;R2](:[c]):[c]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_052(mol): - """AROMFUSED[2]s1""" - query = Chem.MolFromSmarts('[c;R2]([c;H1]):[c;R2]([c;H1])c([C,O,N,P,S,F,Cl,Br,I])[c;H1][c;H1][c;H1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_053(mol): - """AROMFUSED[2]s2""" - query = Chem.MolFromSmarts('[*]!@[c]1[c;H1][c;R2]([c])[c;R2]([c])[c;H1][c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_054(mol): - """AROMFUSED[2]s2s3""" - query = Chem.MolFromSmarts('[*]!@[c]1[c](!@[*])[c;H1][c;R2]([c])[c;R2]([c])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_055(mol): - """AROMFUSED[2]s1s4""" - query = Chem.MolFromSmarts('[c;H1]1[c](!@[*])[c;R2]([c])[c;R2]([c])[c](!@[*])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_056(mol): - """AROMFUSED[2]s1s2""" - query = Chem.MolFromSmarts('[c;H1]1[c](!@[*])[c](!@[*])[c;R2]([c])[c;R2]([c])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_057(mol): - """AROMFUSED[2]s1s3""" - query = Chem.MolFromSmarts('[c;H1]1[c](!@[*])[c;H1][c;R2]([c])[c;R2]([c])[c](!@[*])1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_058(mol): - """AROMFUSED[3]""" - query = Chem.MolFromSmarts('[c][c;R2]1[c;R3]([c])[c;R2]([c])ccc1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_059(mol): - """AROMFUSED[4a]""" - query = Chem.MolFromSmarts('[c;H1][c;R2]1[c;R2]([c])[c;H1][c;R2]([c])[c;R2]([c])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_060(mol): - """AROMFUSED[4a]s1""" - query = Chem.MolFromSmarts('[*]!@[c]1[c;R2]([c])[c;R2]([c])[c;H1][c;R2]([c])[c;R2]1([c])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_061(mol): - """AROMFUSED[4a]s1s4""" - query = Chem.MolFromSmarts('[*]!@[c]1[c;R2]([c])[c;R2]([c])[c](!@[*])[c;R2]([c])[c;R2]1([c])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_062(mol): - """AROMFUSED[4p]""" - query = Chem.MolFromSmarts('[c;H1]1[c;!R1]([c])[c;!R1]([c])[c;!R1]([c])[c;!R1]([c])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_063(mol): - """AROMFUSED[4p]s3s4""" - query = Chem.MolFromSmarts('[*]!@[c]1[c;!R1]([c])[c;!R1]([c])[c;!R1]([c])[c;!R1]([c])[c]1!@[*]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_064(mol): - """PYRIDINE.FUSED[2]""" - query = Chem.MolFromSmarts('[n;H0;R1;D2]1[c;H1;R1;D2][c;H1;R1;D2][c;H1;R1;D2][c;H0;R2;D3](a)[c;H0;R2;D3]1(a)') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_065(mol): - """PYRIDINE.FUSED[2-iso]""" - query = Chem.MolFromSmarts('[n;H0;R1;D2]1[c;H1;R1;D2][c;H1;R1;D2][c;H0;R2;D3](a)[c;H0;R2;D3](a)[c;H1;R1;D2]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_066(mol): - """PYRIDINE.FUSED[4]""" - query = Chem.MolFromSmarts('[n;H0;R1;D2]1[c;H0;R2;D3](a)[c;H0;R2;D3](a)[c;H1;R1;D2][c;H0;R2;D3](a)[c;H0;R2;D3]1(a)') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_067(mol): - """aC-N-CHcyc(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[N]!@&-[C;H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_068(mol): - """N multiring""" - query = Chem.MolFromSmarts('[N;R&!R1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_069(mol): - """Ncyc-(CH2)3-Ncyc(different rings)""" - query = Chem.MolFromSmarts('[N;H0;R;D3]!@&-[C;H2][C;H2][C;H2]!@&-[N;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_070(mol): - """aC-COCH2CH2-aC(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[C;H0](=[O;H0;!R;D1])[C;H2][C;H2]!@&-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_071(mol): - """aC-O-(CH2)2-Ncyc(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[O;H0;D2]-[C;H2][C;H2]!@&-[N;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_072(mol): - """aC-CH(OH)(CH2)2-CHcyc(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[C;H1]([O;H1;!R;D1])-[C;H2][C;H2]!@&-[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_073(mol): - """Ncyc-(CH2)2-CHcyc(different rings)""" - query = Chem.MolFromSmarts('[N;H0;R;D3]!@&-[C;H2][C;H2]!@&-[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_074(mol): - """aC-CONHCH2-CHcyc(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[C;H0;D3](=[O;H0;!R;D1])[N;H1;D2][C;H2]!@&-[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -class Counter: - def __init__(self): - self.loader = Loader() - - self.init_result = { - 'f_001': 0, 'f_002': 0, 'f_003': 0, 'f_004': 0, 'f_005': 0, 'f_006': 0, 'f_007': 0, 'f_008': 0, 'f_009': 0, - 'f_010': 0, 'f_011': 0, 'f_012': 0, 'f_013': 0, 'f_014': 0, 'f_015': 0, 'f_016': 0, 'f_017': 0, 'f_018': 0, - 'f_019': 0, 'f_020': 0, 'f_021': 0, 'f_022': 0, 'f_023': 0, 'f_024': 0, 'f_025': 0, 'f_026': 0, 'f_027': 0, - 'f_028': 0, 'f_029': 0, 'f_030': 0, 'f_031': 0, 'f_032': 0, 'f_033': 0, 'f_034': 0, 'f_035': 0, 'f_036': 0, - 'f_037': 0, 'f_038': 0, 'f_039': 0, 'f_040': 0, 'f_041': 0, 'f_042': 0, 'f_043': 0, 'f_044': 0, 'f_045': 0, - 'f_046': 0, 'f_047': 0, 'f_048': 0, 'f_049': 0, 'f_050': 0, 'f_051': 0, 'f_052': 0, 'f_053': 0, 'f_054': 0, - 'f_055': 0, 'f_056': 0, 'f_057': 0, 'f_058': 0, 'f_059': 0, 'f_060': 0, 'f_061': 0, 'f_062': 0, 'f_063': 0, - 'f_064': 0, 'f_065': 0, 'f_066': 0, 'f_067': 0, 'f_068': 0, 'f_069': 0, 'f_070': 0, 'f_071': 0, 'f_072': 0, - 'f_073': 0, 'f_074': 0, 'f_075': 0, 'f_076': 0, 'f_077': 0, 'f_078': 0, 'f_079': 0, 'f_080': 0, 'f_081': 0, - 'f_082': 0, 'f_083': 0, 'f_084': 0, 'f_085': 0, 'f_086': 0, 'f_087': 0, 'f_088': 0, 'f_089': 0, 'f_090': 0, - 'f_091': 0, 'f_092': 0, 'f_093': 0, 'f_094': 0, 'f_095': 0, 'f_096': 0, 'f_097': 0, 'f_098': 0, 'f_099': 0, - 'f_100': 0, 'f_101': 0, 'f_102': 0, 'f_103': 0, 'f_104': 0, 'f_105': 0, 'f_106': 0, 'f_107': 0, 'f_108': 0, - 'f_109': 0, 'f_110': 0, 'f_111': 0, 'f_112': 0, 'f_113': 0, 'f_114': 0, 'f_115': 0, 'f_116': 0, 'f_117': 0, - 'f_118': 0, 'f_119': 0, 'f_120': 0, 'f_121': 0, 'f_122': 0, 'f_123': 0, 'f_124': 0, 'f_125': 0, 'f_126': 0, - 'f_127': 0, 'f_128': 0, 'f_129': 0, 'f_130': 0, 'f_131': 0, 'f_132': 0, 'f_133': 0, 'f_134': 0, 'f_135': 0, - 'f_136': 0, 'f_137': 0, 'f_138': 0, 'f_139': 0, 'f_140': 0, 'f_141': 0, 'f_142': 0, 'f_143': 0, 'f_144': 0, - 'f_145': 0, 'f_146': 0, 'f_147': 0, 'f_148': 0, 'f_149': 0, 'f_150': 0, 'f_151': 0, 'f_152': 0, 'f_153': 0, - 'f_154': 0, 'f_155': 0, 'f_156': 0, 'f_157': 0, 'f_158': 0, 'f_159': 0, 'f_160': 0, 'f_161': 0, 'f_162': 0, - 'f_163': 0, 'f_164': 0, 'f_165': 0, 'f_166': 0, 'f_167': 0, 'f_168': 0, 'f_169': 0, 'f_170': 0, 'f_171': 0, - 'f_172': 0, 'f_173': 0, 'f_174': 0, 'f_175': 0, 'f_176': 0, 'f_177': 0, 'f_178': 0, 'f_179': 0, 'f_180': 0, - 'f_181': 0, 'f_182': 0, 'f_183': 0, 'f_184': 0, 'f_185': 0, 'f_186': 0, 'f_187': 0, 'f_188': 0, 'f_189': 0, - 'f_190': 0, 'f_191': 0, 'f_192': 0, 'f_193': 0, 'f_194': 0, 'f_195': 0, 'f_196': 0, 'f_197': 0, 'f_198': 0, - 'f_199': 0, 'f_200': 0, 'f_201': 0, 'f_202': 0, 'f_203': 0, 'f_204': 0, 'f_205': 0, 'f_206': 0, 'f_207': 0, - 'f_208': 0, 'f_209': 0, 'f_210': 0, 'f_211': 0, 'f_212': 0, 'f_213': 0, 'f_214': 0, 'f_215': 0, 'f_216': 0, - 'f_217': 0, 'f_218': 0, 'f_219': 0, 'f_220': 0, - - 's_001': 0, 's_002': 0, 's_003': 0, 's_004': 0, 's_005': 0, 's_006': 0, 's_007': 0, 's_008': 0, 's_009': 0, - 's_010': 0, 's_011': 0, 's_012': 0, 's_013': 0, 's_014': 0, 's_015': 0, 's_016': 0, 's_017': 0, 's_018': 0, - 's_019': 0, 's_020': 0, 's_021': 0, 's_022': 0, 's_023': 0, 's_024': 0, 's_025': 0, 's_026': 0, 's_027': 0, - 's_028': 0, 's_029': 0, 's_030': 0, 's_031': 0, 's_032': 0, 's_033': 0, 's_034': 0, 's_035': 0, 's_036': 0, - 's_037': 0, 's_038': 0, 's_039': 0, 's_040': 0, 's_041': 0, 's_042': 0, 's_043': 0, 's_044': 0, 's_045': 0, - 's_046': 0, 's_047': 0, 's_048': 0, 's_049': 0, 's_050': 0, 's_051': 0, 's_052': 0, 's_053': 0, 's_054': 0, - 's_055': 0, 's_056': 0, 's_057': 0, 's_058': 0, 's_059': 0, 's_060': 0, 's_061': 0, 's_062': 0, 's_063': 0, - 's_064': 0, 's_065': 0, 's_066': 0, 's_067': 0, 's_068': 0, 's_069': 0, 's_070': 0, 's_071': 0, 's_072': 0, - 's_073': 0, 's_074': 0, 's_075': 0, 's_076': 0, 's_077': 0, 's_078': 0, 's_079': 0, 's_080': 0, 's_081': 0, - 's_082': 0, 's_083': 0, 's_084': 0, 's_085': 0, 's_086': 0, 's_087': 0, 's_088': 0, 's_089': 0, 's_090': 0, - 's_091': 0, 's_092': 0, 's_093': 0, 's_094': 0, 's_095': 0, 's_096': 0, 's_097': 0, 's_098': 0, 's_099': 0, - 's_100': 0, 's_101': 0, 's_102': 0, 's_103': 0, 's_104': 0, 's_105': 0, 's_106': 0, 's_107': 0, 's_108': 0, - 's_109': 0, 's_110': 0, 's_111': 0, 's_112': 0, 's_113': 0, 's_114': 0, 's_115': 0, 's_116': 0, 's_117': 0, - 's_118': 0, 's_119': 0, 's_120': 0, 's_121': 0, 's_122': 0, 's_123': 0, 's_124': 0, 's_125': 0, 's_126': 0, - 's_127': 0, 's_128': 0, 's_129': 0, 's_130': 0, - - 't_001': 0, 't_002': 0, 't_003': 0, 't_004': 0, 't_005': 0, 't_006': 0, 't_007': 0, 't_008': 0, 't_009': 0, - 't_010': 0, 't_011': 0, 't_012': 0, 't_013': 0, 't_014': 0, 't_015': 0, 't_016': 0, 't_017': 0, 't_018': 0, - 't_019': 0, 't_020': 0, 't_021': 0, 't_022': 0, 't_023': 0, 't_024': 0, 't_025': 0, 't_026': 0, 't_027': 0, - 't_028': 0, 't_029': 0, 't_030': 0, 't_031': 0, 't_032': 0, 't_033': 0, 't_034': 0, 't_035': 0, 't_036': 0, - 't_037': 0, 't_038': 0, 't_039': 0, 't_040': 0, 't_041': 0, 't_042': 0, 't_043': 0, 't_044': 0, 't_045': 0, - 't_046': 0, 't_047': 0, 't_048': 0, 't_049': 0, 't_050': 0, 't_051': 0, 't_052': 0, 't_053': 0, 't_054': 0, - 't_055': 0, 't_056': 0, 't_057': 0, 't_058': 0, 't_059': 0, 't_060': 0, 't_061': 0, 't_062': 0, 't_063': 0, - 't_064': 0, 't_065': 0, 't_066': 0, 't_067': 0, 't_068': 0, 't_069': 0, 't_070': 0, 't_071': 0, 't_072': 0, - 't_073': 0, 't_074': 0, - } - self.result = None - - self.f_order_group_function = \ - [ - f_001, f_002, f_003, f_004, f_005, f_006, f_007, f_008, f_009, f_010, f_011, f_012, f_013, f_014, f_015, - f_016, f_017, f_018, f_019, f_020, f_021, f_022, f_023, f_024, f_025, f_026, f_027, f_028, f_029, f_030, - f_031, f_032, f_033, f_034, f_035, f_036, f_037, f_038, f_039, f_040, f_041, f_042, f_043, f_044, f_045, - f_046, f_047, f_048, f_049, f_050, f_051, f_052, f_053, f_054, f_055, f_056, f_057, f_058, f_059, f_060, - f_061, f_062, f_063, f_064, f_065, f_066, f_067, f_068, f_069, f_070, f_071, f_072, f_073, f_074, f_075, - f_076, f_077, f_078, f_079, f_080, f_081, f_082, f_083, f_084, f_085, f_086, f_087, f_088, f_089, f_090, - f_091, f_092, f_093, f_094, f_095, f_096, f_097, f_098, f_099, f_100, f_101, f_102, f_103, f_104, f_105, - f_106, f_107, f_108, f_109, f_110, f_111, f_112, f_113, f_114, f_115, f_116, f_117, f_118, f_119, f_120, - f_121, f_122, f_123, f_124, f_125, f_126, f_127, f_128, f_129, f_130, f_131, f_132, f_133, f_134, f_135, - f_136, f_137, f_138, f_139, f_140, f_141, f_142, f_143, f_144, f_145, f_146, f_147, f_148, f_149, f_150, - f_151, f_152, f_153, f_154, f_155, f_156, f_157, f_158, f_159, f_160, f_161, f_162, f_163, f_164, f_165, - f_166, f_167, f_168, f_169, f_170, f_171, f_172, f_173, f_174, f_175, f_176, f_177, f_178, f_179, f_180, - f_181, f_182, f_183, f_184, f_185, f_186, f_187, f_188, f_189, f_190, f_191, f_192, f_193, f_194, f_195, - f_196, f_197, f_198, f_199, f_200, f_201, f_202, f_203, f_204, f_205, f_206, f_207, f_208, f_209, f_210, - f_211, f_212, f_213, f_214, f_215, f_216, f_217, f_218, f_219, f_220, - ] - self.s_order_group_function = \ - [ - s_001, s_002, s_003, s_004, s_005, s_006, s_007, s_008, s_009, s_010, s_011, s_012, s_013, s_014, s_015, - s_016, s_017, s_018, s_019, s_020, s_021, s_022, s_023, s_024, s_025, s_026, s_027, s_028, s_029, s_030, - s_031, s_032, s_033, s_034, s_035, s_036, s_037, s_038, s_039, s_040, s_041, s_042, s_043, s_044, s_045, - s_046, s_047, s_048, s_049, s_050, s_051, s_052, s_053, s_054, s_055, s_056, s_057, s_058, s_059, s_060, - s_061, s_062, s_063, s_064, s_065, s_066, s_067, s_068, s_069, s_070, s_071, s_072, s_073, s_074, s_075, - s_076, s_077, s_078, s_079, s_080, s_081, s_082, s_083, s_084, s_085, s_086, s_087, s_088, s_089, s_090, - s_091, s_092, s_093, s_094, s_095, s_096, s_097, s_098, s_099, s_100, s_101, s_102, s_103, s_104, s_105, - s_106, s_107, s_108, s_109, s_110, s_111, s_112, s_113, s_114, s_115, s_116, s_117, s_118, s_119, s_120, - s_121, s_122, s_123, s_124, s_125, s_126, s_127, s_128, s_129, s_130, - ] - self.t_order_group_function = \ - [ - t_001, t_002, t_003, t_004, t_005, t_006, t_007, t_008, t_009, t_010, t_011, t_012, t_013, t_014, t_015, - t_016, t_017, t_018, t_019, t_020, t_021, t_022, t_023, t_024, t_025, t_026, t_027, t_028, t_029, t_030, - t_031, t_032, t_033, t_034, t_035, t_036, t_037, t_038, t_039, t_040, t_041, t_042, t_043, t_044, t_045, - t_046, t_047, t_048, t_049, t_050, t_051, t_052, t_053, t_054, t_055, t_056, t_057, t_058, t_059, t_060, - t_061, t_062, t_063, t_064, t_065, t_066, t_067, t_068, t_069, t_070, t_071, t_072, t_073, t_074, - ] - # basepath = os.path.abspath(__file__) - # folder = os.path.dirname(basepath) - # group_order_file_path = os.path.join(folder, 'group_order.xlsx') - # self.f_order_group_function_order = ( - # pd.read_excel(group_order_file_path, sheet_name='f')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上,因为python列表里的索引是从0开始的 - # self.s_order_group_function_order = ( - # pd.read_excel(group_order_file_path, sheet_name='s')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - # self.t_order_group_function_order = ( - # pd.read_excel(group_order_file_path, sheet_name='t')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - self.f_order_group_function_order, self.s_order_group_function_order, self.t_order_group_function_order = self.loader.load_group_order() - - def count_a_mol(self, mol, clear_mode=False, add_note=False, add_smiles=False): - init_smi = mol - try: - if isinstance(mol, str): - mol = Chem.MolFromSmiles(mol) - self.result = self.init_result.copy() - if add_note: - # self.result['note'] = '' - self.result['note'] = Chem.MolToSmiles(mol) - if add_smiles: - self.result['smiles'] = Chem.MolToSmiles(mol) - self.count_1st_order_groups(mol=mol, add_note=add_note) - self.count_2nd_order_groups(mol=mol) - self.count_3rd_order_groups(mol=mol) - if clear_mode: - # 清爽模式,不显示没有统计到的基团 - self.result = {k: v for k, v in self.result.items() if v} - return self.result - except: - print(f'Error! There is something wrong when counting {init_smi}, please check it.') - return self.init_result.copy() - - def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv', add_note=False, add_smiles=False): # todo mpi 并行? - print('reading the input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplementedError( - 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') - - mol_number = len(smiles_iterator) - print('Done, totally detected {} molecules, start counting...'.format(mol_number)) - count_result_dict_list = [] - for i in tqdm(smiles_iterator): - count_result_dict_list.append(self.count_a_mol(i, add_note=add_note, add_smiles=add_smiles)) - print('Done!') - print('writing to csv...') - result = pd.DataFrame(count_result_dict_list) - result.to_csv(count_result_file_path, index_label='index') - print('Done!') - return result - - def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result.csv', add_note=False, add_smiles=False, n_jobs=1, batch_size='auto'): - print('reading the input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplementedError( - 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') - - mol_number = len(smiles_iterator) - print('Done, totally detected {} molecules, start counting...'.format(mol_number)) - task = [delayed(self.count_a_mol)(i, add_note=add_note, add_smiles=add_smiles) for i in smiles_iterator] - count_result_dict_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('Done!') - print('writing to csv...') - result = pd.DataFrame(count_result_dict_list) - result.to_csv(count_result_file_path, index_label='index') - print('Done!') - return result - - def get_group_fingerprint(self, mol): - count_result = self.count_a_mol(mol=mol, clear_mode=False, add_note=False) - return [value for key, value in count_result.items()] - - def count_1st_order_groups(self, mol, add_note=False): - atoms_index = set([i for i in range(len(mol.GetAtoms()))]) # 所有原子序号的集合 - used_atoms_index = set() - for function_index in self.f_order_group_function_order: - function = self.f_order_group_function[function_index] - _, tuple_of_match_tuples = function(mol) - for matched_index in tuple_of_match_tuples: - if used_atoms_index.intersection(matched_index) == set(): - used_atoms_index = used_atoms_index.union(matched_index) - self.result[function.__name__] += 1 - if used_atoms_index == atoms_index: - # early stop 统计第一顺序基团时,当所有原子都被使用后提前停止 - break - - if used_atoms_index != atoms_index: - warning = 'WARING: For {}, The first order groups do not cover the whole molecule! The results may not be reliable!'.format( - Chem.MolToSmiles(mol)) - print(warning) - if add_note: - if self.result.get('note', 0): - self.result['note'] += '\t' - self.result['note'] += warning - else: - self.result['note'] = warning - return None - - def count_2nd_order_groups(self, mol): - s_order_group_in_mol = [] - for function_index in self.s_order_group_function_order: - function = self.s_order_group_function[function_index] - _, tuple_of_match_tuples = function(mol) - for matched_index in tuple_of_match_tuples: - matched_index = set(matched_index) - for used in s_order_group_in_mol: # for else 语句 - if matched_index.issubset(used): - break - else: - s_order_group_in_mol.append(matched_index) - self.result[function.__name__] += 1 - return None - - def count_3rd_order_groups(self, mol): # 跟count_2nd_order_groups几乎完全一样 - t_order_group_in_mol = [] - for function_index in self.t_order_group_function_order: - function = self.t_order_group_function[function_index] - _, tuple_of_match_tuples = function(mol) - for matched_index in tuple_of_match_tuples: - matched_index = set(matched_index) - for used in t_order_group_in_mol: # for else 语句 - if matched_index.issubset(used): - break - else: - t_order_group_in_mol.append(matched_index) - self.result[function.__name__] += 1 - return None - - -# if __name__ == '__main__': -# print('debug gp_counter.py ...') -# import time -# -# t1 = time.time() -# # m = Chem.MolFromSmiles('Cc1ncc[nH]1') -# c = Counter() -# # result = c.count_a_mol(m, clear_mode=True) -# # print(result) -# # -# # print(c.get_group_fingerprint(m)) -# # -# # # c.count_mols_mpi(smiles_file_path=os.path.join('gp_3x_test_mol', 'SMILES.txt'), count_result_file_path='count_result.csv', add_note=True, -# # # n_jobs=4, batch_size='auto') -# # # -# # # -# # # t2 = time.time() -# # # print(t2 - t1) -# # print(os.path.exists(r'C:\Users\tjulrc\Desktop\Groupy_test\groupy\gp_3x_internal_data\group_order.xlsx')) \ No newline at end of file diff --git a/build/lib/groupy/gp_3x_generator.py b/build/lib/groupy/gp_3x_generator.py deleted file mode 100644 index d60f6fd..0000000 --- a/build/lib/groupy/gp_3x_generator.py +++ /dev/null @@ -1,222 +0,0 @@ -from rdkit import Chem -import os -from tqdm import tqdm -from joblib import Parallel, delayed -import time -import random - -from groupy.gp_convertor import Convertor -from groupy.gp_tool import Tool - - -class Generator: - def __init__(self): - pass - - def calculate_charge(self, smi): - if isinstance(smi, str): - smi = Chem.MolFromSmiles(smi) - net_charge = 0 - all_charge = 0 - for i in smi.GetAtoms(): - net_charge += i.GetFormalCharge() - all_charge += i.GetAtomicNum() - - all_charge -= net_charge - - return net_charge, all_charge - - def calculate_multiplicity(self, smi): - if isinstance(smi, str): - smi = Chem.MolFromSmiles(smi) - net_charge, all_charge = self.calculate_charge(smi) - alpha_minus_beta = all_charge % 2 - multiplicity = alpha_minus_beta + 1 - return multiplicity - - def smi_to_gjf(self, smi, nproc='12', mem='12GB', chk_path=None, gjf_path=None, - gaussian_keywords=None, charge_and_multiplicity=None, - add_other_tasks=False, other_tasks:list=None): - try: - # default path of chk and gjf - if chk_path is None: - chk_path = '{}.chk'.format(smi) - if gjf_path is None: - gjf_path = '{}.gjf'.format(smi) - assert ('(' not in gjf_path) and (')' not in gjf_path), \ - 'gaussian dose not allow ( or ) in the name of .gjf and .chk files' - assert ('(' not in chk_path) and (')' not in chk_path), \ - 'gaussian dose not allow ( or ) in the name of .gjf and .chk files' - # default task - if gaussian_keywords is None: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - # default charge and multiplicity - if charge_and_multiplicity is None: - charge_and_multiplicity = f'{self.calculate_charge(smi)[0]} {self.calculate_multiplicity(smi)}' - # default other tasks - if other_tasks is None: - other_tasks = [ - '#p m062x/def2tzvp geom=check', - '#p m062x/def2tzvp scrf=solvent=water geom=check', - ] - - # read smi - c = Convertor() - temp_xyz_path = f'temp_{str(time.time()) + str(random.randint(0,1000000000000000000))}.xyz' - c.smi_to_xyz(smi=smi, xyz_path=temp_xyz_path) - - # write gjf - # 判断是否存在同名gjf - if os.path.exists(gjf_path): - os.remove(gjf_path) - self.write_gjf_link0_and_keyword(gjf_path=gjf_path, chk_path=chk_path, nproc=nproc, mem=mem, - gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, note=smi) - self.write_gjf_coord(gjf_path=gjf_path, xyz_path=temp_xyz_path) - - if add_other_tasks: - for task_index, task in enumerate(other_tasks): - i_chk_path = chk_path.split('.')[0] + f'_{task_index + 1}' + '.chk' - self.write_gjf_link0_and_keyword(gjf_path=gjf_path, chk_path=i_chk_path, nproc=nproc, mem=mem, - gaussian_keywords=task, charge_and_multiplicity=charge_and_multiplicity, - note=smi, old_chk_path=chk_path, add_link1=True) - self.write_gjf_blank_line(gjf_path=gjf_path, blank_line_number=2) - - # 删除临时xyz文件 - os.remove(temp_xyz_path) - return True - except: - print(f'Error! There is something wrong when converting {smi} to gjf file, please check it.') - return False - - def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, - nproc='12', mem='12GB', chk_path=None, gjf_path=None, - gaussian_keywords=None, charge_and_multiplicity=None, - add_other_tasks=False, other_tasks: list = None, - index_start=0, - ): - smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) - mol_number = len(smiles_iterator) - zfill_number = len(str(mol_number)) + 5 - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - - # make gjf_root_path - if os.path.exists(gjf_root_path): - print('gjf_root_path "{}" has been detected!'.format(gjf_root_path)) - else: - print('gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path)) - os.makedirs(gjf_root_path) - # end - - succeed = [] - fail = [] - for (index, smi) in tqdm(enumerate(smiles_iterator)): - smi = smi.strip() - index += index_start - chk_path = '{}.chk'.format(str(index).zfill(zfill_number)) - gjf_path = os.path.join(gjf_root_path, '{}.gjf'.format(str(index).zfill(zfill_number))) - generate_success_flag = self.smi_to_gjf(smi=smi, nproc=nproc, mem=mem, - chk_path=chk_path, gjf_path=gjf_path, - gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks, - ) - if not generate_success_flag: - fail.append(smi) - else: - succeed.append(smi) - - with open('gjf_fail.txt', 'w') as f: - for i in fail: - f.write(i + '\n') - with open('gjf_succeed.txt', 'w') as f: - for i in succeed: - f.write(i + '\n') - if len(fail) == 0: - print('done! all .gjf files has been saved in {}'.format(gjf_root_path)) - else: - print('Warning! The following SMILES fail to generate .gjf, please check...sorry(OTZ)') - print(fail) - return None - - def batch_smi_to_gjf_mpi(self, smiles_file_path, gjf_root_path=None, - nproc='12', mem='12GB', chk_path=None, gjf_path=None, - gaussian_keywords=None, charge_and_multiplicity=None, - add_other_tasks=False, other_tasks: list = None, - index_start=0, - n_jobs=1, batch_size='auto' - ): - smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) - mol_number = len(smiles_iterator) - zfill_number = len(str(mol_number)) + 5 - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - - # make gjf_root_path - if os.path.exists(gjf_root_path): - print('gjf_root_path "{}" has been detected!'.format(gjf_root_path)) - else: - print('gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path)) - os.makedirs(gjf_root_path) - - # task - task = [delayed(self.smi_to_gjf)(smi=smi, nproc=nproc, mem=mem, - chk_path='{}.chk'.format(str(index).zfill(zfill_number)), - gjf_path=os.path.join(gjf_root_path, '{}.gjf'.format(str(index).zfill(zfill_number))), - gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks,) - for index, smi in enumerate(smiles_iterator)] - result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - return result - - @staticmethod - def write_gjf_link0_and_keyword(gjf_path, chk_path, nproc, mem, gaussian_keywords, charge_and_multiplicity, note, - old_chk_path=None, add_link1=False): - with open(gjf_path, 'a') as gjf: - if add_link1: - gjf.write('--link1--' + '\n') - gjf.write(f'%nproc={nproc}' + '\n') - gjf.write(f'%mem={mem}' + '\n') - if old_chk_path is not None: - gjf.write(f'%oldchk={old_chk_path}' + '\n') - gjf.write(f'%chk={chk_path}' + '\n') - gjf.write(f'{gaussian_keywords}' + '\n') - gjf.write('\n') - gjf.write(f'{note}' + '\n') - gjf.write('\n') - gjf.write(f'{charge_and_multiplicity}' + '\n') - gjf.close() - return None - - @staticmethod - def write_gjf_coord(gjf_path, xyz_path): - xyz = open(xyz_path) - with open(gjf_path, 'a') as gjf: - for i in xyz.readlines()[2:]: - gjf.write(i) - gjf.write('\n\n') - return None - - @staticmethod - def write_gjf_blank_line(gjf_path, blank_line_number=1): - with open(gjf_path, 'a') as gjf: - gjf.write('\n' * blank_line_number) - return None - - - - - -# if __name__ == '__main__': -# -# import time -# g = Generator() -# t1 = time.time() -# # g.smi_to_gjf(smi='C1CCCC1', add_other_tasks=True) -# # g.batch_smi_to_gjf(smiles_file_path='gp_3x_test_mol/3018_with_error_smiles.txt', gjf_root_path='./test_gjf') -# g.batch_smi_to_gjf_mpi(smiles_file_path='gp_3x_test_mol/3018_with_error_smiles.txt', gjf_root_path='./test_gjf', -# add_other_tasks=True, -# n_jobs=8, batch_size='auto') -# -# t2 = time.time() -# print(t2 - t1) \ No newline at end of file diff --git a/build/lib/groupy/gp_3x_loader.py b/build/lib/groupy/gp_3x_loader.py deleted file mode 100644 index 13020aa..0000000 --- a/build/lib/groupy/gp_3x_loader.py +++ /dev/null @@ -1,72 +0,0 @@ -import pandas as pd -import os - - -class Loader: - def __init__(self, ): - basepath = os.path.abspath(__file__) - folder_path1 = os.path.dirname(basepath) - folder_path2 = os.path.join(folder_path1, '..', '..', '..', 'groupy_internal_data') - folder_path3 = os.path.join(folder_path1, '..', '..', '..', '..', 'groupy_internal_data') - - parameters_filename = os.path.join('group_contribution_parameters.xlsx') - group_order_filename = os.path.join('group_order.xlsx') - self.parameters_path = [ - os.path.join(folder_path1, parameters_filename), - os.path.join(folder_path2, parameters_filename), - os.path.join(folder_path3, parameters_filename), - ] - self.group_order_path = [ - os.path.join(folder_path1, group_order_filename), - os.path.join(folder_path2, group_order_filename), - os.path.join(folder_path3, group_order_filename) - ] - - def load_parameters(self, parameter_type='simultaneous', split=False): - assert parameter_type in ['simultaneous', 'step_wise'], '请确保参数类型为simultaneous或step_wise!' - for path in self.parameters_path: - try: - step_wise_first_order = pd.read_excel(path, sheet_name='{}_first_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_second_order = pd.read_excel(path, sheet_name='{}_second_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_third_order = pd.read_excel(path, sheet_name='{}_third_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_universal_constants = pd.read_excel(path, sheet_name='{}_constants'.format(parameter_type), index_col='index').T.to_dict() - break - except: - pass - else: - raise FileNotFoundError(f'Can not find group_contribution_parameters.xlsx in {self.parameters_path}') - - - if split: - return step_wise_first_order, step_wise_second_order, step_wise_third_order, step_wise_universal_constants - else: - return {**step_wise_first_order, **step_wise_second_order, **step_wise_third_order, **step_wise_universal_constants} - - def load_group_order(self): - for path in self.group_order_path: - try: - f_order_group_function_order = ( - pd.read_excel(path, sheet_name='f')[ - 'index'] - 1).tolist() # 减1是为了基团序号和列表索引对上,因为python列表里的索引是从0开始的 - s_order_group_function_order = ( - pd.read_excel(path, sheet_name='s')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - t_order_group_function_order = ( - pd.read_excel(path, sheet_name='t')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - break - except: - pass - else: - raise FileNotFoundError(f'Can not find group_order.xlsx in {self.parameters_path}') - return f_order_group_function_order, s_order_group_function_order, t_order_group_function_order - - -# if __name__ == '__main__': -# -# # debug -# loader = Loader() -# -# # d = loader.load_parameters() -# # for i in d: -# # print(i) -# # print(d[i]) - diff --git a/build/lib/groupy/gp_3x_tool.py b/build/lib/groupy/gp_3x_tool.py deleted file mode 100644 index a41bce3..0000000 --- a/build/lib/groupy/gp_3x_tool.py +++ /dev/null @@ -1,56 +0,0 @@ -import pandas as pd - - -class Tool: - def __init__(self): - pass - - def __repr__(self): - return 'This is a object implemented some useful tools' - - @staticmethod - def load_smiles_iterator(smiles_file_path): - print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplemented('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - smiles_iterator = [i.strip() for i in smiles_iterator] - return smiles_iterator - - -def export_a_dict(result_dict, export_path='result.csv'): - df = pd.DataFrame([result_dict]) - df.to_csv(export_path, index_label='index') - return None - - -logo = \ -''' ---------------------------------------------------------------------------------- -Groupy -- A Useful Tool for Molecular Analysis -Developer: Ruichen Liu -Hint: Please feel easy to contact the developer if you have any problems in use. -E-mail1: liuruichen@tju.edu.cn -E-mail2: 1197748182@qq.com (may reply more quickly than E-mail1) ---------------------------------------------------------------------------------- -''' - - -# if __name__ == '__main__': -# print('debug gp_tool.py') -# -# t = Tool() -# -# t.smi_to_xyz('C1CCCC1') -# # t.batch_smi_to_xyz(smiles_file_path=r'gp_3x_test_mol\SMILES.txt', xyz_root_path='test_xyz') -# # t.convert_file_type(in_format='xyz', in_path='C1CCCC1.xyz', out_format='mol2') -# # t.batch_convert_file_type(in_format='xyz', in_root_path='test_xyz', out_format='mol2', out_root_path=None) -# # t.batch_convert_file_type(in_format='xyz', in_root_path='test_xyz', out_format='mol2', out_root_path='test_mol2') -# -# t.smi_to_gjf(smi='C1CCC1', add_other_std_tasks=True) -# t.batch_smi_to_gjf(smiles_file_path=r'gp_3x_test_mol\SMILES.txt', gjf_root_path='test_gjf', add_other_std_tasks=True) diff --git a/build/lib/groupy/gp_3x_viewer.py b/build/lib/groupy/gp_3x_viewer.py deleted file mode 100644 index e1b0740..0000000 --- a/build/lib/groupy/gp_3x_viewer.py +++ /dev/null @@ -1,96 +0,0 @@ -import ase -from ase.visualize import view -from ase.io import read -import os -from pprint import pprint - -from groupy.gp_convertor import Convertor - - -class Viewer: - def __init__(self): - pass - - def view_mol(self, mol, mol_type='smi'): - if isinstance(mol, str): - if mol_type in ['smi', 'smiles', 'SMILES']: - convertor = Convertor() - convertor.smi_to_xyz(smi=mol, xyz_path='temp.xyz') - mol = read(filename='temp.xyz', format='xyz') - os.remove('temp.xyz') - else: - try: - mol = read(filename=mol, format=mol_type) - except: - convertor = Convertor() - convertor.convert_file_type(in_format=mol_type, in_path=mol, out_format='xyz', out_path='temp.xyz') - mol = read(filename='temp.xyz', format='xyz') - os.remove('temp.xyz') - view(mol) - - def plot_supported_format(self): - ase_format = ase.io.formats.ioformats - openbabel_format = {'abinit': 'ABINIT Output Format', 'acesout': 'ACES output format', 'acr': 'ACR format', - 'adfband': 'ADF Band output format', 'adfdftb': 'ADF DFTB output format', 'adfout': 'ADF output format', - 'alc': 'Alchemy format', 'aoforce': 'Turbomole AOFORCE output format', - 'arc': 'Accelrys/MSI Biosym/Insight II CAR format', 'axsf': 'XCrySDen Structure Format', - 'bgf': 'MSI BGF format', 'box': 'Dock 3.5 Box format', 'bs': 'Ball and Stick format', - 'c09out': 'Crystal 09 output format', 'c3d1': 'Chem3D Cartesian 1 format', 'c3d2': 'Chem3D Cartesian 2 format', - 'caccrt': 'Cacao Cartesian format', 'can': 'Canonical SMILES format', - 'car': 'Accelrys/MSI Biosym/Insight II CAR format', 'castep': 'CASTEP format', 'ccc': 'CCC format', - 'cdjson': 'ChemDoodle JSON', 'cdx': 'ChemDraw binary format', 'cdxml': 'ChemDraw CDXML format', - 'cif': 'Crystallographic Information File', 'ck': 'ChemKin format', 'cml': 'Chemical Markup Language', - 'cmlr': 'CML Reaction format', 'cof': 'Culgi object file format', 'CONFIG': 'DL-POLY CONFIG', - 'CONTCAR': 'VASP format', 'CONTFF': 'MDFF format', 'crk2d': 'Chemical Resource Kit diagram(2D)', - 'crk3d': 'Chemical Resource Kit 3D format', 'ct': 'ChemDraw Connection Table format', - 'cub': 'Gaussian cube format', 'cube': 'Gaussian cube format', 'dallog': 'DALTON output format', - 'dalmol': 'DALTON input format', 'dat': 'Generic Output file format', 'dmol': 'DMol3 coordinates format', - 'dx': 'OpenDX cube format for APBS', 'ent': 'Protein Data Bank format', - 'exyz': 'Extended XYZ cartesian coordinates format', 'fa': 'FASTA format', 'fasta': 'FASTA format', - 'fch': 'Gaussian formatted checkpoint file format', 'fchk': 'Gaussian formatted checkpoint file format', - 'fck': 'Gaussian formatted checkpoint file format', 'feat': 'Feature format', 'fhiaims': 'FHIaims XYZ format', - 'fract': 'Free Form Fractional format', 'fs': 'Fastsearch format', 'fsa': 'FASTA format', - 'g03': 'Gaussian Output', 'g09': 'Gaussian Output', 'g16': 'Gaussian Output', - 'g92': 'Gaussian Output', 'g94': 'Gaussian Output', 'g98': 'Gaussian Output', 'gal': 'Gaussian Output', - 'gam': 'GAMESS Output', 'gamess': 'GAMESS Output', 'gamin': 'GAMESS Input', 'gamout': 'GAMESS Output', - 'got': 'GULP format', 'gpr': 'Ghemical format', 'gro': 'GRO format', 'gukin': 'GAMESS-UK Input', - 'gukout': 'GAMESS-UK Output', 'gzmat': 'Gaussian Z-Matrix Input', 'hin': 'HyperChem HIN format', - 'HISTORY': 'DL-POLY HISTORY', 'inchi': 'InChI format', 'inp': 'GAMESS Input', 'ins': 'ShelX format', - 'jin': 'Jaguar input format', 'jout': 'Jaguar output format', 'log': 'Generic Output file format', - 'lpmd': 'LPMD format', 'mcdl': 'MCDL format', 'mcif': 'Macromolecular Crystallographic Info', - 'MDFF': 'MDFF format', 'mdl': 'MDL MOL format', 'ml2': 'Sybyl Mol2 format', - 'mmcif': 'Macromolecular Crystallographic Info', 'mmd': 'MacroModel format', 'mmod': 'MacroModel format', - 'mol': 'MDL MOL format', 'mol2': 'Sybyl Mol2 format', 'mold': 'Molden format', 'molden': 'Molden format', - 'molf': 'Molden format', 'moo': 'MOPAC Output format', 'mop': 'MOPAC Cartesian format', - 'mopcrt': 'MOPAC Cartesian format', 'mopin': 'MOPAC Internal', 'mopout': 'MOPAC Output format', - 'mpc': 'MOPAC Cartesian format', 'mpo': 'Molpro output format', 'mpqc': 'MPQC output format', - 'mrv': 'Chemical Markup Language', 'msi': 'Accelrys/MSI Cerius II MSI format', 'nwo': 'NWChem output format', - 'orca': 'ORCA output format', 'out': 'Generic Output file format', 'outmol': 'DMol3 coordinates format', - 'output': 'Generic Output file format', 'pc': 'PubChem format', 'pcjson': 'PubChem JSON', - 'pcm': 'PCModel Format', 'pdb': 'Protein Data Bank format', 'pdbqt': 'AutoDock PDBQT format', - 'png': 'PNG 2D depiction', 'pos': 'POS cartesian coordinates format', 'POSCAR': 'VASP format', - 'POSFF': 'MDFF format', 'pqr': 'PQR format', 'pqs': 'Parallel Quantum Solutions format', - 'prep': 'Amber Prep format', 'pwscf': 'PWscf format', 'qcout': 'Q-Chem output format', 'res': 'ShelX format', - 'rsmi': 'Reaction SMILES format', 'rxn': 'MDL RXN format', 'sd': 'MDL MOL format', 'sdf': 'MDL MOL format', - 'siesta': 'SIESTA format', 'smi': 'SMILES format', 'smiles': 'SMILES format', - 'smy': 'SMILES format using Smiley parser', 'sy2': 'Sybyl Mol2 format', 't41': 'ADF TAPE41 format', - 'tdd': 'Thermo format', 'text': 'Read and write raw text', 'therm': 'Thermo format', - 'tmol': 'TurboMole Coordinate format', 'txt': 'Title format', 'txyz': 'Tinker XYZ format', - 'unixyz': 'UniChem XYZ format', 'VASP': 'VASP format', 'vmol': 'ViewMol format', - 'wln': 'Wiswesser Line Notation', 'xml': 'General XML format', 'xsf': 'XCrySDen Structure Format', - 'xyz': 'XYZ cartesian coordinates format', 'yob': 'YASARA.org YOB format'} - print('-' * 40) - pprint('ASE format:') - pprint(ase_format) - print('-' * 40) - pprint('OpenBabel format:') - pprint(openbabel_format) - return None - - - -# if __name__ == '__main__': -# -# viewer = Viewer() -# viewer.view_mol('gp_3x_test_mol/test_xyz/000000.mol2', mol_type='mol2') -# viewer.plot_supported_format() \ No newline at end of file diff --git a/build/lib/groupy/groupy.py b/build/lib/groupy/groupy.py deleted file mode 100644 index 8e976c0..0000000 --- a/build/lib/groupy/groupy.py +++ /dev/null @@ -1,559 +0,0 @@ -from rdkit import Chem - -from gp_3x_calculator import Calculator -from gp_3x_counter import Counter -from gp_3x_viewer import Viewer -from gp_3x_convertor import Convertor -from gp_3x_generator import Generator -from gp_3x_tool import Tool, export_a_dict, logo - - -def main_function_5(): - - while True: - flag_file = input( - '\n' - '--------------------------------------------------------------------------------- \n' - 'You are in main function 5 \n' - 'what to do? \n' - ' 0. return to main interface. \n' - ' 1. generate a .xyz file by input SMILES of a molecule. \n' - ' 2. generate a batch of .xyz files. -2. use mpi to accelerate.\n' - ' 3. convert a file to other format (e.g. xyz, mol, mol2, pdb...) \n' - ' 4. convert a batch of file to other format. -4. use mpi to accelerate.\n' - ' 5. generate .gjf(input file of gaussian) file by input SMILES of a molecule. \n' - ' 6. generate a batch of .gjf files. -6. use mpi to accelerate. \n' - '--------------------------------------------------------------------------------- \n' - ) - - - if flag_file == '0': - break - - elif flag_file == '1': - sub_function_1_of_main_function_5() - - elif flag_file == '2': - sub_function_2_of_main_function_5() - - elif flag_file == '-2': - sub_function_minus_2_of_main_function_5() - - elif flag_file == '3': - sub_function_3_of_main_function_5() - - elif flag_file == '4': - sub_function_4_of_main_function_5() - - elif flag_file == '-4': - sub_function_minus_4_of_main_function_5() - - elif flag_file == '5': - sub_function_5_of_main_function_5() - - elif flag_file == '6': - sub_function_6_of_main_function_5() - - elif flag_file == '-6': - sub_function_minus_6_of_main_function_5() - - return None - - -def sub_function_1_of_main_function_5(): - """ - 1. generate a .xyz file by input SMILES of a molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - xyz_file = input( - 'please input the path of output .xyz file. If press Enter directly, {}.xyz will be used\n'.format(smiles)) - if not xyz_file: - xyz_file = '{}.xyz'.format(smiles) - convertor = Convertor() - convertor.smi_to_xyz(smi=smiles, xyz_path=xyz_file) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_2_of_main_function_5(): - """ - 2. generate a batch of .xyz files. - """ - smiles_file_path = input('input a filepath of a file in which save SMILES of molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - xyz_root_path = input('input the root path of output xyz files, that is, ' - 'all the output xyz files will be make in this path. e.g. test_xyz \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n') - convertor = Convertor() - convertor.batch_smi_to_xyz(smiles_file_path=smiles_file_path, xyz_root_path=xyz_root_path) - print('\n\n\n') - return None - - -def sub_function_minus_2_of_main_function_5(): - """ - 2. generate a batch of .xyz files. use mpi - """ - smiles_file_path = input('input a filepath of a file in which save SMILES of molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - xyz_root_path = input('input the root path of output xyz files, that is, ' - 'all the output xyz files will be make in this path. e.g. test_xyz \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - convertor = Convertor() - convertor.batch_smi_to_xyz_mpi(smiles_file_path=smiles_file_path, xyz_root_path=xyz_root_path, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def sub_function_3_of_main_function_5(): - """ - 3. convert a file to other format (e.g. xyz, mol, mol2, pdb...) - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_path = input('please input the path of input file, e.g. C1CCC1.xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - out_path = input('please input the path of output file, e.g C1CCC1.mol2. \n') - convertor = Convertor() - convertor.convert_file_type(in_format=in_format, in_path=in_path, out_format=out_format, out_path=out_path) - print('Done!') - print('\n\n\n') - - -def sub_function_4_of_main_function_5(): - """ - 4. convert a batch of file to other format. - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_root_path = input('please input the root path of input files, that is, ' - 'all input files you want to convert should be in there.' - 'e.g. test_xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - default_out_root_path = in_root_path - out_root_path = input('please input the root path of output file, that is, ' - 'all the output files will be saved in there\n' - 'If press Enter directly, {} will be used \n'.format(default_out_root_path)) - if not out_root_path: - out_root_path = default_out_root_path - convertor = Convertor() - convertor.batch_convert_file_type(in_format=in_format, in_root_path=in_root_path, - out_format=out_format, out_root_path=out_root_path) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_minus_4_of_main_function_5(): - """ - -4. convert a batch of file to other format. use mpi. - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_root_path = input('please input the root path of input files, that is, ' - 'all input files you want to convert should be in there.' - 'e.g. test_xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - default_out_root_path = in_root_path - out_root_path = input('please input the root path of output file, that is, ' - 'all the output files will be saved in there\n' - 'If press Enter directly, {} will be used \n'.format(default_out_root_path)) - if not out_root_path: - out_root_path = default_out_root_path - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - convertor = Convertor() - convertor.batch_convert_file_type_mpi(in_format=in_format, in_root_path=in_root_path, - out_format=out_format, out_root_path=out_root_path, - n_jobs=n_jobs, batch_size=batch_size) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_5_of_main_function_5(): - """ - 5. generate .gjf(input file of gaussian) file by input SMILES of a molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - chk_path = input('input the path of chk file. e.g. Cc1ccccc1.chk \n' - 'Hint1: If press Enter directly, {}.chk will be used. \n' - 'Hint2: Attention please! the symbol such as (, ), /, \\ and # should not appear in a filepath! \n' - .format(smiles)) - if not chk_path: - chk_path = '{}.chk'.format(smiles) - - gjf_path = input('input the path of gjf file. e.g. Cc1ccccc1.gjf \n' - 'Hint1: If press Enter directly, {}.gjf will be used. \n' - 'Hint2: Attention please! the symbol such as (, ), /, \\ and # should not appear in a filepath! \n' - .format(smiles)) - if not gjf_path: - gjf_path = '{}.gjf'.format(smiles) - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - generator = Generator() - generator.smi_to_gjf(smi=smiles, nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - chk_path=chk_path, gjf_path=gjf_path, - add_other_tasks=add_other_tasks, other_tasks=other_tasks) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_6_of_main_function_5(): - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - - gjf_root_path = input('Input the root path of output gjf files, that is, ' - 'all the output gjf files will be make in this path. e.g. test_gjf \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: if press Enter directly, test_gjf will be used. \n') - if not gjf_root_path: - gjf_root_path = 'test_gjf' - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - generator = Generator() - generator.batch_smi_to_gjf(smiles_file_path=smiles_file_path, gjf_root_path=gjf_root_path, - nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks) - print('\n\n\n') - return None - - -def sub_function_minus_6_of_main_function_5(): - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - - gjf_root_path = input('Input the root path of output gjf files, that is, ' - 'all the output gjf files will be make in this path. e.g. test_gjf \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: if press Enter directly, test_gjf will be used. \n') - if not gjf_root_path: - gjf_root_path = 'test_gjf' - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - - generator = Generator() - generator.batch_smi_to_gjf_mpi(smiles_file_path=smiles_file_path, gjf_root_path=gjf_root_path, - nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main_function_view(): - viewer = Viewer() - show_flag = input('show a SMILES (enter 1) or file (enter 2). \n(enter help to show supported file formats)\n') - if show_flag in ['1', 'SMILES', 'smiles']: - smiles = input('input the SMILES of a molecule. \n') - viewer.view_mol(mol=smiles, mol_type='smi') - - elif show_flag in ['2', 'file', 'FILE']: - file_path = input('input the file path you want to show. e.g. ./temporary.xyz \n') - file_type = input('input file format. e.g. xyz \n') - viewer.view_mol(mol=file_path, mol_type=file_type) - - elif show_flag in ['h', 'help', 'H', 'Help', 'HELP']: - viewer.plot_supported_format() - - else: - print('Unrecognized command!') - return None - - -def main_function_1(): - """ - 1. calculate properties of a molecule by input SMILES of this molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - calculator = Calculator() - result = calculator.calculate_a_mol(smiles, debug=False) - print(result) - export_flag = input('Do you want to export results to a csv file? (y/n) \n') - if export_flag in ['y', 'Y', '1']: - export_a_dict(result_dict=result, export_path='{}_calculate.csv'.format(smiles)) - print('the results have been export to {}_calculate.csv! \n\n'.format(smiles)) - else: - print('\n\n\n') - return None - - -def main_function_2(): - """ - 2. count group number of a molecule by input SMILES of this molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - mol = Chem.MolFromSmiles(smiles) - counter = Counter() - clear_mode_flag = input('clear mode? (y/n) \n') - if clear_mode_flag in ['y', 'Y', '1']: - result = counter.count_a_mol(mol, clear_mode=True) - else: - result = counter.count_a_mol(mol, clear_mode=False) - print(result) - export_flag = input('Do you want to export results to a file? (y/n) \n') - if export_flag in ['y', 'Y', '1']: - export_a_dict(result_dict=result, export_path='{}_count.csv'.format(smiles)) - print('the results have been export to {}_count.csv! \n\n'.format(smiles)) - else: - print('\n\n\n') - return None - - -def main_function_3(): - """ - 3. calculate properties of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - calculator = Calculator() - calculator.calculate_mols(smiles_file_path=smiles_file_path, - properties_file_path='batch_calculate_results.csv') - print('\n\n\n') - return None - - -def main_function_minus_3(): - """ - -3. calculate properties of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - use mpi - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - calculator = Calculator() - calculator.calculate_mols_mpi(smiles_file_path=smiles_file_path, - properties_file_path='batch_calculate_results_mpi.csv', - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main_function_4(): - """ - 4. count group number of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - """ - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - counter = Counter() - counter.count_mols(smiles_file_path=smiles_file_path, - count_result_file_path='batch_count_result.csv', add_note=True, add_smiles=True) - print('\n\n\n') - return None - - -def main_function_minus_4(): - """ - -4. count group number of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - use mpi - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - counter = Counter() - counter.count_mols_mpi(smiles_file_path=smiles_file_path, - count_result_file_path='batch_count_result_mpi.csv', add_note=True, add_smiles=True, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main(): - print(logo) - - while True: - - flag_main = input( - '\n' - '--------------------------------------------------------------------------------- \n' - 'You are in main interface \n' - 'what to do? \n' - ' q. exit \n' - ' 0. show molecular structure by SMILES or file. \n' - ' 1. calculate properties of a molecule. \n' - ' 2. count group number of a molecule. \n' - ' 3. calculate properties of a batch of molecules. -3. use mpi to accelerate.\n' - ' 4. count group number of a batch of molecules. -4. use mpi to accelerate.\n' - ' 5. generate files or covert file format for MD, DFT, Visualization... \n' - '--------------------------------------------------------------------------------- \n' - ) - - if flag_main == 'q': - print('exit Groupy, have a nice day!') - break - - elif flag_main == '0': - main_function_view() - - elif flag_main == '1': - main_function_1() - - elif flag_main == '2': - main_function_2() - - elif flag_main == '3': - main_function_3() - - elif flag_main == '-3': - main_function_minus_3() - - elif flag_main == '4': - main_function_4() - - elif flag_main == '-4': - main_function_minus_4() - - elif flag_main == '5': - main_function_5() - - else: - print('Please input the right option. ' - 'For more information, you are supposed to read the manual.') - - -if __name__ == '__main__': - - main() diff --git a/build/lib/src/__init__.py b/build/lib/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/build/lib/src/gp_3x_calculator.py b/build/lib/src/gp_3x_calculator.py deleted file mode 100644 index 68dbecf..0000000 --- a/build/lib/src/gp_3x_calculator.py +++ /dev/null @@ -1,288 +0,0 @@ -from rdkit import Chem -from math import log -import pandas as pd -from tqdm import tqdm -from joblib import Parallel, delayed - -from gp_3x_loader import Loader -from gp_3x_counter import Counter - - -class Calculator: - def __init__(self): - self.loader = Loader() - self.counter = Counter() - self.parameters_step_wise = self.loader.load_parameters(parameter_type='step_wise', split=False) - self.parameters_simultaneous = self.loader.load_parameters(parameter_type='simultaneous', split=False) - - def __repr__(self): - return '这是一个使用基团贡献法计算分子性质的计算器' - - @staticmethod - def Tm(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: # 这样写是取了字典的键 - right_side_eq += group_number[i] * parameters[i]['Tm'] - Tm = parameters[1]['Tm0'] * log(max(right_side_eq, 1.0)) # 这里的1.0是为了上计算结果小于0K的都设置为0K - return round(Tm, 3) - - @staticmethod - def Tb(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Tb'] - Tb = parameters[1]['Tb0'] * log(max(right_side_eq, 1.0)) # 这里的1.0是为了上计算结果小于0K的都设置为0K - return round(Tb, 3) - - @staticmethod - def Tc(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Tc'] - Tc = parameters[1]['Tc0'] * log(max(right_side_eq, 1.0)) # 这里的1.0是为了上计算结果小于0K的都设置为0K - return round(Tc, 3) - - @staticmethod - def Pc(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Pc'] - Pc = parameters[1]['Pc1'] + (right_side_eq + parameters[1]['Pc2']) ** -2 - return round(Pc, 4) - - @staticmethod - def Vc(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Vc'] - Vc = parameters[1]['Vc0'] + right_side_eq - return round(Vc, 2) - - @staticmethod - def delta_Gf(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Gf'] - delta_Gf = parameters[1]['Gf0'] + right_side_eq - return round(delta_Gf, 3) - - @staticmethod - def delta_Hf(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Hf'] - delta_Hf = parameters[1]['Hf0'] + right_side_eq - return round(delta_Hf, 3) - - @staticmethod - def delta_Hv(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Hv'] - delta_Hv = parameters[1]['Hv0'] + right_side_eq - return round(delta_Hv, 3) - - @staticmethod - def delta_Hfus(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Hfus'] - delta_Hfus = parameters[1]['Hfus0'] + right_side_eq - return round(delta_Hfus, 3) - - @staticmethod - def flash_point(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Fp'] - Fp = parameters[1]['Fp0'] + right_side_eq - return round(Fp, 3) - - @staticmethod - def molar_volume(group_number, parameters): - right_side_eq = 0.00 - for i in group_number: - right_side_eq += group_number[i] * parameters[i]['Vm'] - Vm = parameters[1]['Vm0'] + right_side_eq - return round(Vm, 3) - - @staticmethod - def density(molar_mass, Vs): - return round(molar_mass / (1000 * Vs), 3) - - @staticmethod - def delta_Hc(C_number, H_number, delta_Hf): - delta_Hc = -(-395.51 * C_number - 241.83 * H_number / 2 - delta_Hf) - return round(delta_Hc, 3) - - @staticmethod - def q(delta_Hc, molar_mass): - return round(delta_Hc / molar_mass, 3) - - @staticmethod - def isp(C_number, H_number, q): - H_C_ratio = H_number / C_number - parameter = q * (11.91 + H_C_ratio) / (43.66 + 8.936 * H_C_ratio) - if parameter < 0: # todo这是无意中发现的错误,可能是因为算的不是碳氢分子(确实目前发现的出现错误的是含F原子的分子) - parameter = 0 - isp = (2 * 0.556 * parameter) ** 0.5 / 9.8 * 1000 - return round(isp, 3) - - @staticmethod - def C_number(mol): - C_atoms = [i for i in mol.GetAtoms() if i.GetAtomicNum() == 6] - return len(C_atoms) - - @staticmethod - def H_number(mol): - H_atoms = [i for i in Chem.AddHs(mol).GetAtoms() if i.GetAtomicNum() == 1] - return len(H_atoms) - - @staticmethod - def smiles(mol): - return Chem.MolToSmiles(mol) - - @staticmethod - def molar_mass(mol): - molar_mass = 0.000 - atoms = Chem.AddHs(mol).GetAtoms() - for i in atoms: - molar_mass += i.GetMass() - return molar_mass - - def calculate_a_mol(self, mol, parameter_type='step_wise', debug=False): - init_smi = mol - try: - if isinstance(mol, str): - mol = Chem.MolFromSmiles(mol) - group_number = self.counter.count_a_mol(mol, clear_mode=True, add_note=True) - if group_number.get('note', ''): - counter_note = group_number['note'] - del group_number['note'] - else: - counter_note = '' - - if parameter_type == 'step_wise': - parameters = self.parameters_step_wise # todo 这里是速度慢的原因,每算一个分子都要加载一遍参数,要修改 - elif parameter_type == 'simultaneous': - parameters = self.parameters_simultaneous - else: - raise NotImplemented('不可用的参数类型,只能使用step_wise或simultaneous') - - if debug: - print(group_number) - # print(parameters) - Tm = self.Tm(group_number=group_number, parameters=parameters) - Tb = self.Tb(group_number=group_number, parameters=parameters) - Tc = self.Tc(group_number=group_number, parameters=parameters) - Pc = self.Pc(group_number=group_number, parameters=parameters) - Vc = self.Vc(group_number=group_number, parameters=parameters) - delta_Gf = self.delta_Gf(group_number=group_number, parameters=parameters) - delta_Hf = self.delta_Hf(group_number=group_number, parameters=parameters) - delta_Hv = self.delta_Hv(group_number=group_number, parameters=parameters) - delta_Hfus = self.delta_Hfus(group_number=group_number, parameters=parameters) - C_number = self.C_number(mol) - H_number = self.H_number(mol) - molar_mass = self.molar_mass(mol) - flash_point = self.flash_point(group_number=group_number, parameters=parameters) - molar_volume = self.molar_volume(group_number=group_number, parameters=parameters) - density = self.density(molar_mass=molar_mass, Vs=molar_volume) - delta_Hc = self.delta_Hc(C_number=C_number, H_number=H_number, delta_Hf=delta_Hf) - q = self.q(delta_Hc=delta_Hc, molar_mass=molar_mass) - isp = self.isp(C_number=C_number, H_number=H_number, q=q) - smiles = self.smiles(mol) - return {'smiles': smiles, - 'molar_mass': molar_mass, - 'flash_point/K': flash_point, - 'Tm/K': Tm, 'Tb/K': Tb, 'Tc/K': Tc, - 'Pc/bar': Pc, 'Vc/(cm3/mol)': Vc, - 'density/(g/cm3)': density, - 'delta_G/(KJ/mol)': delta_Gf, - 'delta_Hf/(KJ/mol)': delta_Hf, - 'delta_Hvap/(KJ/mol)': delta_Hv, - 'delta_Hfus/(KJ/mol)': delta_Hfus, - 'molar_volume/(cm3/mol)(default298K)': molar_volume, - 'delta_Hc/(KJ/mol)': delta_Hc, - 'mass_calorific_value_h/(MJ/kg)': q, - 'ISP': isp, - 'note': counter_note + ' at 298K'} - except: - print(f'Error! There is something wrong when calculating {init_smi}, please check it.') - return {'smiles': init_smi, - 'molar_mass': '?', - 'flash_point/K': '?', - 'Tm/K': '?', 'Tb/K': '?', 'Tc/K': '?', - 'Pc/bar': '?', 'Vc/(cm3/mol)': '?', - 'density/(g/cm3)': '?', - 'delta_G/(KJ/mol)': '?', - 'delta_Hf/(KJ/mol)': '?', - 'delta_Hvap/(KJ/mol)': '?', - 'delta_Hfus/(KJ/mol)': '?', - 'molar_volume/(cm3/mol)(default298K)': '?', - 'delta_Hc/(KJ/mol)': '?', - 'mass_calorific_value_h/(MJ/kg)': '?', - 'ISP': '?', - 'note': 'There must be something wrong with this SMILES'} - - def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.csv', parameter_type='simultaneous'): # todo 还没实现不同步拟合的参数的使用 - print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - return None - mol_number = len(smiles_iterator) - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - print('start calculating...') - properties_dict_list = [] - error_smi = [] - for i in tqdm(smiles_iterator): - try: - properties_dict_list.append(self.calculate_a_mol(i, parameter_type=parameter_type)) - except: - error_smi.append(i) - print('calculation completed!') - print('start to export result to {} ...'.format(properties_file_path)) - result = pd.DataFrame(properties_dict_list) - result.to_csv(properties_file_path, index_label='index') - with open('error.txt', 'w') as f: - for i in error_smi: - f.write(i + '\n') - print('Done!') - return result - - def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_result_mpi.csv', parameter_type='simultaneous', n_jobs=1, batch_size='auto'): - print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - return None - mol_number = len(smiles_iterator) - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - print('start calculating...') - task = [delayed(self.calculate_a_mol)(i, parameter_type=parameter_type) for i in smiles_iterator] - properties_dict_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('calculation completed!') - print('start to export result to {} ...'.format(properties_file_path)) - result = pd.DataFrame(properties_dict_list) - result.to_csv(properties_file_path, index_label='index') - print('Done!') - return result - - -# if __name__ == '__main__': -# import time -# t1 = time.time() -# c = Calculator() -# c.calculate_mols_mpi(smiles_file_path='gp_3x_test_mol/SMILES.txt', n_jobs=4) -# t2 = time.time() -# print(t2 - t1) \ No newline at end of file diff --git a/build/lib/src/gp_3x_convertor.py b/build/lib/src/gp_3x_convertor.py deleted file mode 100644 index 4d2960e..0000000 --- a/build/lib/src/gp_3x_convertor.py +++ /dev/null @@ -1,345 +0,0 @@ -import os -from rdkit import Chem -from rdkit.Chem import AllChem -from openbabel import pybel -import pandas as pd -from tqdm import tqdm -from joblib import Parallel, delayed - - -class Convertor: - def __init__(self): - pass - - def __repr__(self): - return ('This is a object implemented some functions that can convert SMILES to 3D chemical files,' - ' such as xyz, gro... or convert 3D chemical files to SMILES') - - @staticmethod - def load_smiles_iterator(smiles_file_path): - print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplemented('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - smiles_iterator = [i.strip() for i in smiles_iterator] - return smiles_iterator - - @staticmethod - def smi_to_xyz(smi, xyz_path=None): - mol = Chem.MolFromSmiles(smi) - if mol is None: - print(f'can not read {smi}, please check your SMILES') - return False - - mol_with_h = Chem.AddHs(mol) - AllChem.EmbedMolecule(mol_with_h, useRandomCoords=True) - atom_number = len(mol_with_h.GetAtoms()) - try: - AllChem.MMFFOptimizeMolecule(mol_with_h) - opt = Chem.MolToMolBlock(mol_with_h) - except ValueError: - mol = pybel.readstring("smi", smi) - mol.addh() - if mol.make3D() is None: - opt = mol.write("mol") - else: - print(f'Error! There is something wrong when converting {smi} to xyz file, please check it.') - return False - - # Windows 下pybel有问题 - # try: - # mol = pybel.readstring("smi", smi) - # mol.addh() - # if mol.make3D() is None: - # opt = mol.write("mol") - # else: - # return False - # except: - # AllChem.MMFFOptimizeMolecule(mol_with_h) - # opt = Chem.MolToMolBlock(mol_with_h) - - if xyz_path is None: - xyz_path = smi + '.xyz' - with open(xyz_path, 'w') as file: - file.write('{}\n'.format(atom_number)) - file.write(smi + '\n') - for index, i in enumerate(opt.splitlines()[4:]): - if len(i.split()) >= 4: - if i.split()[3].isupper(): - file.write(i.split()[3] + ' ') - file.write(i.split()[0] + ' ') - file.write(i.split()[1] + ' ') - file.write(i.split()[2] + '\n') - return True - - def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path): - smiles_iterator = self.load_smiles_iterator(smiles_file_path=smiles_file_path) - mol_number = len(smiles_iterator) - zfill_number = len(str(mol_number)) + 3 - print('reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number)) - # make xyz_root_path - if os.path.exists(xyz_root_path): - print('xyz_root_path "{}" has been detected!'.format(xyz_root_path)) - else: - print('xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path)) - os.makedirs(xyz_root_path) - # end - - succeed = [] - fail = [] - for (index, smi) in tqdm(enumerate(smiles_iterator)): - smi = smi.strip() - out_name = os.path.join(xyz_root_path, '{}.xyz'.format(str(index).zfill(zfill_number))) - generate_success_flag = self.smi_to_xyz(smi=smi, xyz_path=out_name) - - if not generate_success_flag: - fail.append(smi) - else: - succeed.append(smi) - - with open('xyz_fail.txt', 'w') as f: - for i in fail: - f.write(i + '\n') - with open('xyz_succeed.txt', 'w') as f: - for i in succeed: - f.write(i + '\n') - - if len(fail) == 0: - print('done! all .xyz files has been saved in {}'.format(xyz_root_path)) - else: - print('Warning! The following SMILES fail to generate .xyz, please check...sorry(OTZ)') - print(fail) - return None - - def batch_smi_to_xyz_mpi(self, smiles_file_path, xyz_root_path, n_jobs=1, batch_size='auto'): - smiles_iterator = self.load_smiles_iterator(smiles_file_path=smiles_file_path) - mol_number = len(smiles_iterator) - zfill_number = len(str(mol_number)) + 3 - print('reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number)) - # make xyz_root_path - if os.path.exists(xyz_root_path): - print('xyz_root_path "{}" has been detected!'.format(xyz_root_path)) - else: - print('xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path)) - os.makedirs(xyz_root_path) - # end - - task = [delayed(self.smi_to_xyz)(smi=smi, xyz_path=os.path.join(xyz_root_path, '{}.xyz'.format(str(index).zfill(zfill_number)))) for (index, smi) in enumerate(smiles_iterator)] - result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('done! all .xyz files has been saved in {}'.format(xyz_root_path)) - return result - - @staticmethod - def convert_file_type(in_format, in_path, out_format, out_path=None): - try: - mol = pybel.readfile(in_format, in_path).__next__() - # print('The SMILES of this system is :') - # print(mol.write('smi')) - - if out_path is None: - out_path = in_path.split('.') - out_path = out_path[0] + '.' + out_format - mol.write(out_format, out_path, overwrite=True) - return None - except: - print(f'Error! There is something wrong when converting {in_path}, please check it.') - return None - - def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_path=None): - if out_root_path is None: - out_root_path = in_root_path - else: - # make out_root_path - if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) - else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - out_file_names = [i.split('.')[0] + '.{}'.format(out_format) for i in in_file_names] - - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - out_file_path = [os.path.join(out_root_path, i) for i in out_file_names] - - error_in_file_path = [] - for index in tqdm(range(len(in_file_path))): - try: - self.convert_file_type(in_format=in_format, in_path=in_file_path[index], - out_format=out_format, out_path=out_file_path[index]) - except: - # print('Warning!!!') - error_in_file_path.append(in_file_path[index]) - # print('There may something wrong in {}, please check it carefully!'.format(in_file_path[index])) - - # When there is something wrong, print some warning - if len(error_in_file_path) > 0: - print('Warning!Warning!Warning!') - for i in error_in_file_path: - print('There may something wrong in {}, please check it carefully!'.format(i)) - return None - - def batch_convert_file_type_mpi(self, in_format, in_root_path, out_format, out_root_path=None, n_jobs=1, batch_size='auto'): - if out_root_path is None: - out_root_path = in_root_path - else: - # make out_root_path - if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) - else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - out_file_names = [i.split('.')[0] + '.{}'.format(out_format) for i in in_file_names] - - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - out_file_path = [os.path.join(out_root_path, i) for i in out_file_names] - - task = [delayed(self.convert_file_type)(in_format=in_format, in_path=in_file_path[index],out_format=out_format, out_path=out_file_path[index]) for index in range(len(in_file_path))] - result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - return result - - @staticmethod - def file_to_smi(file_path, format=None): - """ - {'abinit': 'ABINIT Output Format', 'acesout': 'ACES output format', 'acr': 'ACR format', - 'adfband': 'ADF Band output format', 'adfdftb': 'ADF DFTB output format', 'adfout': 'ADF output format', - 'alc': 'Alchemy format', 'aoforce': 'Turbomole AOFORCE output format', - 'arc': 'Accelrys/MSI Biosym/Insight II CAR format', 'axsf': 'XCrySDen Structure Format', - 'bgf': 'MSI BGF format', 'box': 'Dock 3.5 Box format', 'bs': 'Ball and Stick format', - 'c09out': 'Crystal 09 output format', 'c3d1': 'Chem3D Cartesian 1 format', 'c3d2': 'Chem3D Cartesian 2 format', - 'caccrt': 'Cacao Cartesian format', 'can': 'Canonical SMILES format', - 'car': 'Accelrys/MSI Biosym/Insight II CAR format', 'castep': 'CASTEP format', 'ccc': 'CCC format', - 'cdjson': 'ChemDoodle JSON', 'cdx': 'ChemDraw binary format', 'cdxml': 'ChemDraw CDXML format', - 'cif': 'Crystallographic Information File', 'ck': 'ChemKin format', 'cml': 'Chemical Markup Language', - 'cmlr': 'CML Reaction format', 'cof': 'Culgi object file format', 'CONFIG': 'DL-POLY CONFIG', - 'CONTCAR': 'VASP format', 'CONTFF': 'MDFF format', 'crk2d': 'Chemical Resource Kit diagram(2D)', - 'crk3d': 'Chemical Resource Kit 3D format', 'ct': 'ChemDraw Connection Table format', - 'cub': 'Gaussian cube format', 'cube': 'Gaussian cube format', 'dallog': 'DALTON output format', - 'dalmol': 'DALTON input format', 'dat': 'Generic Output file format', 'dmol': 'DMol3 coordinates format', - 'dx': 'OpenDX cube format for APBS', 'ent': 'Protein Data Bank format', - 'exyz': 'Extended XYZ cartesian coordinates format', 'fa': 'FASTA format', 'fasta': 'FASTA format', - 'fch': 'Gaussian formatted checkpoint file format', 'fchk': 'Gaussian formatted checkpoint file format', - 'fck': 'Gaussian formatted checkpoint file format', 'feat': 'Feature format', 'fhiaims': 'FHIaims XYZ format', - 'fract': 'Free Form Fractional format', 'fs': 'Fastsearch format', 'fsa': 'FASTA format', - 'g03': 'Gaussian Output', 'g09': 'Gaussian Output', 'g16': 'Gaussian Output', - 'g92': 'Gaussian Output', 'g94': 'Gaussian Output', 'g98': 'Gaussian Output', 'gal': 'Gaussian Output', - 'gam': 'GAMESS Output', 'gamess': 'GAMESS Output', 'gamin': 'GAMESS Input', 'gamout': 'GAMESS Output', - 'got': 'GULP format', 'gpr': 'Ghemical format', 'gro': 'GRO format', 'gukin': 'GAMESS-UK Input', - 'gukout': 'GAMESS-UK Output', 'gzmat': 'Gaussian Z-Matrix Input', 'hin': 'HyperChem HIN format', - 'HISTORY': 'DL-POLY HISTORY', 'inchi': 'InChI format', 'inp': 'GAMESS Input', 'ins': 'ShelX format', - 'jin': 'Jaguar input format', 'jout': 'Jaguar output format', 'log': 'Generic Output file format', - 'lpmd': 'LPMD format', 'mcdl': 'MCDL format', 'mcif': 'Macromolecular Crystallographic Info', - 'MDFF': 'MDFF format', 'mdl': 'MDL MOL format', 'ml2': 'Sybyl Mol2 format', - 'mmcif': 'Macromolecular Crystallographic Info', 'mmd': 'MacroModel format', 'mmod': 'MacroModel format', - 'mol': 'MDL MOL format', 'mol2': 'Sybyl Mol2 format', 'mold': 'Molden format', 'molden': 'Molden format', - 'molf': 'Molden format', 'moo': 'MOPAC Output format', 'mop': 'MOPAC Cartesian format', - 'mopcrt': 'MOPAC Cartesian format', 'mopin': 'MOPAC Internal', 'mopout': 'MOPAC Output format', - 'mpc': 'MOPAC Cartesian format', 'mpo': 'Molpro output format', 'mpqc': 'MPQC output format', - 'mrv': 'Chemical Markup Language', 'msi': 'Accelrys/MSI Cerius II MSI format', 'nwo': 'NWChem output format', - 'orca': 'ORCA output format', 'out': 'Generic Output file format', 'outmol': 'DMol3 coordinates format', - 'output': 'Generic Output file format', 'pc': 'PubChem format', 'pcjson': 'PubChem JSON', - 'pcm': 'PCModel Format', 'pdb': 'Protein Data Bank format', 'pdbqt': 'AutoDock PDBQT format', - 'png': 'PNG 2D depiction', 'pos': 'POS cartesian coordinates format', 'POSCAR': 'VASP format', - 'POSFF': 'MDFF format', 'pqr': 'PQR format', 'pqs': 'Parallel Quantum Solutions format', - 'prep': 'Amber Prep format', 'pwscf': 'PWscf format', 'qcout': 'Q-Chem output format', 'res': 'ShelX format', - 'rsmi': 'Reaction SMILES format', 'rxn': 'MDL RXN format', 'sd': 'MDL MOL format', 'sdf': 'MDL MOL format', - 'siesta': 'SIESTA format', 'smi': 'SMILES format', 'smiles': 'SMILES format', - 'smy': 'SMILES format using Smiley parser', 'sy2': 'Sybyl Mol2 format', 't41': 'ADF TAPE41 format', - 'tdd': 'Thermo format', 'text': 'Read and write raw text', 'therm': 'Thermo format', - 'tmol': 'TurboMole Coordinate format', 'txt': 'Title format', 'txyz': 'Tinker XYZ format', - 'unixyz': 'UniChem XYZ format', 'VASP': 'VASP format', 'vmol': 'ViewMol format', - 'wln': 'Wiswesser Line Notation', 'xml': 'General XML format', 'xsf': 'XCrySDen Structure Format', - 'xyz': 'XYZ cartesian coordinates format', 'yob': 'YASARA.org YOB format'} - """ - try: - atoms = next(pybel.readfile(format=format, filename=file_path)) - smi = atoms.write(format='smi').split('\t')[0] - # print(smi) - return smi - except: - print('There may something wrong in {}, please check it carefully!'.format(file_path)) - return 'There may something wrong in {}, please check it carefully!'.format(file_path) - - def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None): - if out_root_path is None: - out_root_path = in_root_path - else: - # make out_root_path - if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) - else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - error_in_file_path = [] - smi_list = [] - for index in tqdm(range(len(in_file_path))): - try: - smi_list.append(self.file_to_smi(format=in_format, file_path=in_file_path[index])) - except: - # print('Warning!!!') - error_in_file_path.append(in_file_path[index]) - # print('There may something wrong in {}, please check it carefully!'.format(in_file_path[index])) - - # When there is something wrong, print some warning - if len(error_in_file_path) > 0: - print('Warning!Warning!Warning!') - for i in error_in_file_path: - print('There may something wrong in {}, please check it carefully!'.format(i)) - - with open(os.path.join(out_root_path, 'SMILES.txt'), 'w') as f: - for i in smi_list: - f.write(i + '\n') - return smi_list - - def batch_file_to_smi_mpi(self, in_format, in_root_path, out_root_path=None, n_jobs=1, batch_size='auto'): - if out_root_path is None: - out_root_path = in_root_path - else: - # make out_root_path - if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) - else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - - task = [delayed(self.file_to_smi)(format=in_format, file_path=in_file_path[index]) for index in range(len(in_file_path))] - smi_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - - with open(os.path.join(out_root_path, 'SMILES.txt'), 'w') as f: - for i in smi_list: - f.write(i + '\n') - return smi_list - - -# if __name__ == '__main__': -# import time -# -# t1 = time.time() -# c = Convertor() -# # c.smi_to_xyz('C1CCCC1C', 'C1CCCC1C.xyz') -# # c.convert_file_type(in_format='xyz', in_path='C1CCCC1C.xyz', out_format='mol', out_path='C1CCCC1C.mol') -# # c.file_to_smi('C1CCCC1C.mol', format='mol') -# -# # c.batch_file_to_smi(in_format='mol2', in_root_path=os.path.join('gp_3x_test_mol', 'test_mol')) -# x = c.batch_file_to_smi_mpi(in_format='mol', in_root_path='./test', n_jobs=4, batch_size='auto') -# t2 = time.time() -# print(t2 - t1) \ No newline at end of file diff --git a/build/lib/src/gp_3x_counter.py b/build/lib/src/gp_3x_counter.py deleted file mode 100644 index 2d2cd8b..0000000 --- a/build/lib/src/gp_3x_counter.py +++ /dev/null @@ -1,3374 +0,0 @@ -import os.path -from tqdm import tqdm -from rdkit import Chem -import pandas as pd -from joblib import Parallel, delayed - - -# tool -def has_non_aromatic_neighbor(atom): - """判断原子周围是否有非芳香原子""" - flag = False - neighbors = atom.GetNeighbors() - for i in neighbors: - if not i.GetIsAromatic(): - flag = True - break - return flag - - -def find_ring_atoms(mol, atom_idxs): - """找出给定的原子索引元组中在环上的原子索引,并返回一个元组""" - return list((i for i in atom_idxs if mol.GetAtomWithIdx(i).IsInRing())) - - -def is_in_same_ring(mol, atom_idxs: tuple): - """判断一个元组中的原子索引是否在同一个环上""" - flag = False - all_rings = [list(i) for i in Chem.GetSymmSSSR(mol)] - # set(i).issubset(j) - for i in all_rings: - if set(atom_idxs).issubset(i): - flag = True - break - return flag - - -# f order -def f_001(mol): - """CH3""" - query = Chem.MolFromSmarts('[C;H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_002(mol): - """CH2""" - query = Chem.MolFromSmarts('[C;H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_003(mol): - """CH""" - query = Chem.MolFromSmarts('[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_004(mol): - """C""" - query = Chem.MolFromSmarts('[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_005(mol): - """CH2=CH""" - query = Chem.MolFromSmarts('[C;H2;!R]=[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_006(mol): - """CH=CH""" - query = Chem.MolFromSmarts('[C;H;!R]=[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_007(mol): - """CH2=C""" - query = Chem.MolFromSmarts('[C;H2;!R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_008(mol): - """CH=C""" - query = Chem.MolFromSmarts('[C;H1;!R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_009(mol): - """C=C""" - query = Chem.MolFromSmarts('[C;H0;!R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_010(mol): - """CH2=C=CH""" - query = Chem.MolFromSmarts('[C;H2;!R]=[C;H0;!R;D2]=[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_011(mol): - """CH2=C=C""" - query = Chem.MolFromSmarts('[C;H2;!R]=[C;H0;!R;D2]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_012(mol): - """C=C=C - 2001年的论文12号基团是CH=C=CH,对应的SMART为[C;H1;!R;D2]=[C;H0;!R;D2]=[C;H1;!R;D2] - """ - query = Chem.MolFromSmarts('[C;H0;!R]=[C;H0;!R;D2]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_013(mol): - """CH#C""" - query = Chem.MolFromSmarts('[C;H1;!R;D1]#[C;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_014(mol): - """C#C""" - query = Chem.MolFromSmarts('[C;H0;!R;D2]#[C;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_015(mol): - """aCH""" - query = Chem.MolFromSmarts('[c;H1;R1;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_016(mol): # 麻烦且慢,但是应该可靠 - """aC fused with aromatic ring""" - query = Chem.MolFromSmarts('[c;H0;R&!R1;D3]') - match_list = mol.GetSubstructMatches(query) - real_match_list = [] - for i in match_list: - atom_i = mol.GetAtomWithIdx(i[0]) - if not has_non_aromatic_neighbor(atom_i): # 判断该原子是否有链接非芳香原子是为了防止把芳环上连接非芳香环也算进去 - real_match_list.append(i) - return len(real_match_list), tuple(real_match_list) - - -def f_017(mol): # 麻烦且慢,但是应该可靠 - """aC fused with nonaromatic subring""" - query = Chem.MolFromSmarts('[c;H0;R&!R1;D3]') - match_list = mol.GetSubstructMatches(query) - real_match_list = [] - for i in match_list: - atom_i = mol.GetAtomWithIdx(i[0]) - if has_non_aromatic_neighbor(atom_i): # 判断该原子是否有链接非芳香原子是为了取出芳环上连接非芳香环的,和上一个函数刚好相反 - real_match_list.append(i) - return len(real_match_list), tuple(real_match_list) - - -def f_018(mol): - """aC except as above - 这里这样写是因为我们给出了数基团的顺序,在那里会剔除重复的,所以不用担心重复 - """ - query = Chem.MolFromSmarts('[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_019(mol): - """aN in aromatic ring""" - query = Chem.MolFromSmarts('[n;H0;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_020(mol): - """aC-CH3""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_021(mol): - """aC-CH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_022(mol): - """aC-CH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_023(mol): - """aC-C""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_024(mol): - """aC-CH=CH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D2]=[C;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_025(mol): - """aC-CH=CH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D2]=[C;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_026(mol): - """aC-C=CH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D3]=[C;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_027(mol): - """aC-C#CH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D2]#[C;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_028(mol): - """aC-C#C""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D2]#[C;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_029(mol): - """OH""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_030(mol): - """aC-OH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_031(mol): - """COOH""" - query = Chem.MolFromSmarts('[C;H0;!R;D3]([O;H1;!R;D1])=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_032(mol): - """aC-COOH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3]([O;H1;!R;D1])=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_033(mol): - """CH3CO""" - query = Chem.MolFromSmarts('[C;H3;!R;D1][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_034(mol): - """CH2CO""" - query = Chem.MolFromSmarts('[C;H2;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_035(mol): - """CHCO""" - query = Chem.MolFromSmarts('[C;H1;!R][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_036(mol): - """CCO""" - query = Chem.MolFromSmarts('[C;H0;!R][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_037(mol): - """aC-CO""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_038(mol): - """CHO""" - query = Chem.MolFromSmarts('[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_039(mol): - """aC-CHO""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_040(mol): - """CH3COO""" - query = Chem.MolFromSmarts('[C;H3;!R;D1][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_041(mol): - """CH2COO""" - query = Chem.MolFromSmarts('[C;H2;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_042(mol): - """CHCOO""" - query = Chem.MolFromSmarts('[C;H1;!R;D3][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_043(mol): - """CCOO""" - query = Chem.MolFromSmarts('[C;H0;!R;D4][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_044(mol): - """HCOO""" - query = Chem.MolFromSmarts('[C;H1;!R;D2](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_045(mol): - """aC-COO""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_046(mol): - """aC-OOCH""" - query = Chem.MolFromSmarts('[C;H1;!R;D2](=[O;H0;!R;D1])[O;H0;!R;D2][c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_047(mol): - """aC-OOC""" - query = Chem.MolFromSmarts('[C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2][c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_048(mol): - """COO except as above - 取消了是不是在环上的限制 - """ - query = Chem.MolFromSmarts('[C;H0;D3](=[O;H0;D1])[O;H0;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_049(mol): - """CH3O""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_050(mol): - """CH2O""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_051(mol): - """CH-O""" - query = Chem.MolFromSmarts('[C;H1;!R]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_052(mol): - """C-O""" - query = Chem.MolFromSmarts('[C;H0;!R]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_053(mol): - """aC-O""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_054(mol): - """CH2NH2""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_055(mol): - """CHNH2""" - query = Chem.MolFromSmarts('[C;H1;!R;D3]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_056(mol): - """CNH2""" - query = Chem.MolFromSmarts('[C;H0;!R;D4]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_057(mol): - """CH3NH""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_058(mol): - """CH2NH""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_059(mol): - """CHNH""" - query = Chem.MolFromSmarts('[C;H1;!R;D3]-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_060(mol): - """CH3N - 因为有f66(CH=N)、f67(C=N),所以这里还是要求N的度为3 - """ - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[N;H0;!R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_061(mol): - """CH2N - 因为有f66(CH=N)、f67(C=N),所以这里还是要求N的度为3 - """ - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;H0;!R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_062(mol): - """aC-NH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_063(mol): - """aC-NH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_064(mol): - """aC-N""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0;!R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_065(mol): - """NH2 except as above""" - query = Chem.MolFromSmarts('[N;H2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_066(mol): - """CH=N""" - query = Chem.MolFromSmarts('[C;H1;!R;D2]=[N;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_067(mol): - """C=N""" - query = Chem.MolFromSmarts('[C;H0;!R]=[N;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_068(mol): - """CH2CN""" - query = Chem.MolFromSmarts('[C;H2;!R;D2][C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_069(mol): - """CHCN""" - query = Chem.MolFromSmarts('[C;H1;!R;D3][C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_070(mol): - """CCN""" - query = Chem.MolFromSmarts('[C;H0;!R;D4][C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_071(mol): - """aC-CN""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_072(mol): - """CN except as above - 不可能在环上 - """ - query = Chem.MolFromSmarts('[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_073(mol): - """CH2NCO""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;H0;!R;D2]=[C;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_074(mol): - """CHNCO""" - query = Chem.MolFromSmarts('[C;H1;!R]-[N;H0;!R;D2]=[C;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_075(mol): - """CNCO""" - query = Chem.MolFromSmarts('[C;H0;!R]-[N;H0;!R;D2]=[C;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_076(mol): - """aC-NCO""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0;!R;D2]=[C;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_077(mol): - """CH2NO2""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_078(mol): - """CHNO2""" - query = Chem.MolFromSmarts('[C;H1;!R;D3]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_079(mol): - """CNO2""" - query = Chem.MolFromSmarts('[C;H0;!R;D4]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_080(mol): - """aC-NO2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_081(mol): - """NO2 except as above""" - query = Chem.MolFromSmarts('[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_082(mol): - """ONO""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][N;H0;!R;D2]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_083(mol): - """ONO2""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][N;+]([O;-1])=O') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_084(mol): - """HCON(CH2)2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H1;!R;D2][N;H0;!R;D3]([C;H2;!R;D2])[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_085(mol): - """HCONH(CH2)""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H1;!R;D2][N;H1;!R;D2][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_086(mol): - """CONH2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_087(mol): - """CONHCH3""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H1;!R;D2][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_088(mol): - """CONHCH2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H1;!R;D2][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_089(mol): - """CON(CH3)2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H0;!R;D3]([C;H3;!R;D1])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_090(mol): - """CONCH3CH2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H0;!R;D3]([C;H2;!R;D2])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_091(mol): - """CON(CH2)2""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H0;!R;D3]([C;H2;!R;D2])[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_092(mol): - """CONHCO""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H1;!R;D2][C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_093(mol): - """CONCO""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3][N;H0;!R;D3][C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_094(mol): - """aC-CONH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_095(mol): - """aC-NH(CO)H""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_096(mol): - """aC-N(CO)H""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0;!R;D3]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_097(mol): - """aC-CONH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])[N;H1;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_098(mol): - """aC-NHCO""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_099(mol): - """aC-NCO""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0;!R;D3]-[C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_100(mol): - """NHCONH""" - query = Chem.MolFromSmarts('[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_101(mol): - """NH2CONH""" - query = Chem.MolFromSmarts('[N;H2;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_102(mol): - """NH2CON""" - query = Chem.MolFromSmarts('[N;H2;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_103(mol): - """NHCON""" - query = Chem.MolFromSmarts('[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_104(mol): - """NCON""" - query = Chem.MolFromSmarts('[N;H0;!R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_105(mol): - """aC-NHCONH2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_106(mol): - """aC-NHCONH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H1;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_107(mol): - """NHCO except as above""" - query = Chem.MolFromSmarts('[N;H1;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_108(mol): - """CH2Cl""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_109(mol): - """CHCl""" - query = Chem.MolFromSmarts('[C;H1;!R][Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_110(mol): - """CCl""" - query = Chem.MolFromSmarts('[C;H0;!R][Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_111(mol): - """CHCl2""" - query = Chem.MolFromSmarts('[C;H1;!R;D3](-[Cl])-[Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_112(mol): - """CCl2""" - query = Chem.MolFromSmarts('[C;H0;!R](-[Cl])-[Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_113(mol): - """CCl3""" - query = Chem.MolFromSmarts('[C;H0;!R;D4](-[Cl])(-[Cl])-[Cl]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_114(mol): - """CH2F""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_115(mol): - """CHF""" - query = Chem.MolFromSmarts('[C;H1;!R][F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_116(mol): - """CF""" - query = Chem.MolFromSmarts('[C;H0;!R][F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_117(mol): - """CHF2""" - query = Chem.MolFromSmarts('[C;H1;!R;D3](-[F])-[F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_118(mol): - """CF2""" - query = Chem.MolFromSmarts('[C;H0;!R](-[F])-[F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_119(mol): - """CF3""" - query = Chem.MolFromSmarts('[C;H0;!R;D4](-[F])(-[F])-[F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_120(mol): - """CCl2F""" - query = Chem.MolFromSmarts('F[C;H0;!R;D4](Cl)Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_121(mol): - """HCClF""" - query = Chem.MolFromSmarts('F[C;H1;!R;D3]Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_122(mol): - """CClF2""" - query = Chem.MolFromSmarts('Cl[C;H0;!R;D4](F)F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_123(mol): - """aC-Cl""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_124(mol): - """aC-F""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_125(mol): - """aC-I""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-I') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_126(mol): - """aC-Br""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-Br') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_127(mol): - """I except as above""" - query = Chem.MolFromSmarts('I') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_128(mol): - """Br except as above""" - query = Chem.MolFromSmarts('Br') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_129(mol): - """F except as above""" - query = Chem.MolFromSmarts('F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_130(mol): - """Cl except as above""" - query = Chem.MolFromSmarts('Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_131(mol): - """CHNOH""" - query = Chem.MolFromSmarts('[C;H1;!R;D2]=[N;H0;!R;D2]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_132(mol): - """CNOH""" - query = Chem.MolFromSmarts('[C;H0;!R]=[N;H0;!R;D2]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_133(mol): - """aC-CHNOH""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D2]=[N;H0;!R;D2]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_134(mol): - """OCH2CH2OH""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][C;H2;!R;D2][C;H2;!R;D2][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_135(mol): - """OCHCH2OH""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][C;H1;!R;D3][C;H2;!R;D2][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_136(mol): - """OCH2CHOH""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][C;H2;!R;D2][C;H1;!R;D3][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_137(mol): - """O-OH""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_138(mol): - """CH2SH""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_139(mol): - """CHSH - 这里要求C是D3,是因为后面又142(-SH(except as above)) - """ - query = Chem.MolFromSmarts('[C;H1;!R;D3]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_140(mol): - """CSH - 这里要求C是D4,是因为后面又142(-SH(except as above)) - """ - query = Chem.MolFromSmarts('[C;H0;!R;D4]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_141(mol): - """aC-SH""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_142(mol): - """SH except as above""" - query = Chem.MolFromSmarts('[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_143(mol): - """CH3S""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_144(mol): - """CH2S""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_145(mol): - """CHS""" - query = Chem.MolFromSmarts('[C;H1;!R]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_146(mol): - """CS""" - query = Chem.MolFromSmarts('[C;H0;!R]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_147(mol): - """aC-S-""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_148(mol): - """SO""" - query = Chem.MolFromSmarts('[S;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_149(mol): - """SO2""" - query = Chem.MolFromSmarts('[S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_150(mol): - """SO3(sulfite)""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][S;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_151(mol): - """SO3(sulfonate)""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_152(mol): - """SO4(sulfite)""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_153(mol): - """aC-SO""" - query = Chem.MolFromSmarts('[c;H0;R;D3][S;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_154(mol): - """aC-SO2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -# todo 含磷化合物太复杂了,以后再检查吧 -def f_155(mol): - """PH(phosphine)""" - query = Chem.MolFromSmarts('[P;H1;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_156(mol): - """P(phosphine)""" - query = Chem.MolFromSmarts('[P;H0;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_157(mol): - """PO3(phosphine)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H0;D3]([O;D2;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_158(mol): - """PHO3(phosphonate)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H1;D3](=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_159(mol): - """PO3(phosphonate)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H0;D4](=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_160(mol): - """PHO4(phosphate)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H0;D4]([O;H1;D1])(=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_161(mol): - """PO4(phosphate)""" - query = Chem.MolFromSmarts('[O;D2;H0][P;H0;D4]([O;H0;D2])(=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_162(mol): - """aC-PO4""" - query = Chem.MolFromSmarts('[c;H0;R][O;D2;H0][P;H0;D4]([O;H0;D2])(=[O;D1;H0])[O;D2;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_163(mol): - """aC-P""" - query = Chem.MolFromSmarts('[c;H0;R][P;H0;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_164(mol): - """CO3(carbonate)""" - query = Chem.MolFromSmarts('[O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_165(mol): - """C2H3O""" - query = Chem.MolFromSmarts('[C;H2;R;D2]1[O;H0;R;D2][C;H1;R;D3]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_166(mol): - """C2H2O""" - query = Chem.MolFromSmarts('[C;H2;R;D2]1[O;H0;R;D2][C;H0;R]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_167(mol): - """C2O""" - query = Chem.MolFromSmarts('[C;H1;R][O;H0;R][C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_168(mol): - """CH2(cyclic)""" - query = Chem.MolFromSmarts('[C;H2;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_169(mol): - """CH(cyclic)""" - query = Chem.MolFromSmarts('[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_170(mol): - """C(cyclic)""" - query = Chem.MolFromSmarts('[C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_171(mol): - """CH=CH(cyclic)""" - query = Chem.MolFromSmarts('[C;H1;D2]=[C;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_172(mol): - """CH=C(cyclic)""" - query = Chem.MolFromSmarts('[C;H1;D2]=[C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_173(mol): - """C=C(cyclic)""" - query = Chem.MolFromSmarts('[C;H0]=[C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_174(mol): - """CH2=C(cyclic)""" - query = Chem.MolFromSmarts('[C;H2;D1]=[C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_175(mol): - """NH(cyclic)""" - query = Chem.MolFromSmarts('[N;H1;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_176(mol): - """N(cyclic)""" - query = Chem.MolFromSmarts('[N;H0;D3;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_177(mol): # todo 芳香性 - """CH=N(cyclic)""" - # '[C,c;H1]=,:[N,n;H0;D2;R]' - query1 = Chem.MolFromSmarts('[C;H1]=[N;H0;D2;R]') - match_list = mol.GetSubstructMatches(query1) - return len(match_list), match_list - - -def f_178(mol): # todo 芳香性 - """C=N(cyclic)""" - query = Chem.MolFromSmarts('[C;H0]=[N;H0;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_179(mol): - """O(cyclic)""" - query = Chem.MolFromSmarts('[O;H0;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_180(mol): - """CO(cyclic)""" - query = Chem.MolFromSmarts('[C;H0;R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_181(mol): # todo 芳香性 - """S(cyclic)""" - query = Chem.MolFromSmarts('[S,s;H0;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_182(mol): # todo 芳香性 - """SO2(cyclic)""" - query = Chem.MolFromSmarts('[O;H0;D1;!R]=[S,s;H0;D4;R]=[O;H0;D1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -# todo 183-220都没有官方的例子 -def f_183(mol): - """>NH""" - query = Chem.MolFromSmarts('[N;H1;D3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_184(mol): - """-O-""" - query = Chem.MolFromSmarts('[O;H0;D2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_185(mol): - """-S-""" - query = Chem.MolFromSmarts('[S;H0;D2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_186(mol): - """>CO""" - query = Chem.MolFromSmarts('[C;H0;D3;!R]=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_187(mol): - """PO2""" - query = Chem.MolFromSmarts('[O;H0;!R][P;!R][O;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_188(mol): - """CH-N""" - query = Chem.MolFromSmarts('[C;H1;!R]-[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_189(mol): - """SiHO""" - query = Chem.MolFromSmarts('[Si;H1;!R]=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_190(mol): - """SiO""" - query = Chem.MolFromSmarts('[Si;H0;!R]=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_191(mol): - """SiH2""" - query = Chem.MolFromSmarts('[Si;H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_192(mol): - """SiH1""" - query = Chem.MolFromSmarts('[Si;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_193(mol): - """Si""" - query = Chem.MolFromSmarts('[Si;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_194(mol): - """(CH3)3N""" - query = Chem.MolFromSmarts('[C;H3][N;!R]([C;H3])[C;H3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_195(mol): - """N=N""" - query = Chem.MolFromSmarts('[N;H0;!R]=[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_196(mol): - """Ccyc=N-""" - query = Chem.MolFromSmarts('[C;H0;R]=[N;H0;D2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_197(mol): - """Ccyc=CH-""" - query = Chem.MolFromSmarts('[C;H0;R]=[C;H1;D2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_198(mol): - """Ccyc=NH""" - query = Chem.MolFromSmarts('[C;H0;R]=[N;H1;D1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_199(mol): - """N=O""" - query = Chem.MolFromSmarts('[N;H0;!R]=[O;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_200(mol): - """Ccyc=C""" - query = Chem.MolFromSmarts('[C;H0;R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_201(mol): - """P=O""" - query = Chem.MolFromSmarts('[P;H0;!R]=[O;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_202(mol): - """N=N""" - query = Chem.MolFromSmarts('[N;H0;!R]=[N;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_203(mol): - """C=NH""" - query = Chem.MolFromSmarts('[C;H0;!R]=[N;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_204(mol): - """>C=S""" - query = Chem.MolFromSmarts('[C;H0;!R;D3]=[S;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_205(mol): - """aC-CON""" - query = Chem.MolFromSmarts('[c]!@&-[C](=[O;H0;D1])[N;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_206(mol): - """aC=O""" - query = Chem.MolFromSmarts('[c]!@&=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_207(mol): - """aN-""" - query = Chem.MolFromSmarts('[n;H0;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_208(mol): - """-Na""" - query = Chem.MolFromSmarts('[Na]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_209(mol): - """-K""" - query = Chem.MolFromSmarts('[K]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_210(mol): - """HCONH""" - query = Chem.MolFromSmarts('[C;H1;!R](=[O;H0;D1])[N;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_211(mol): - """CHOCH""" - query = Chem.MolFromSmarts('[C;H1;!R](=[O;H0;D1])[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_212(mol): - """C2O""" - query = Chem.MolFromSmarts('[C;H0;R]1[O;H0;R][C;H0;R]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_213(mol): - """SiH3""" - query = Chem.MolFromSmarts('[Si;H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_214(mol): # todo ? - """SiH2O""" - query = Chem.MolFromSmarts('[Si;H2;!R][O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_215(mol): - """CH=C=CH""" - query = Chem.MolFromSmarts('[C;H1;!R]=[C;H0;!R]=[C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_216(mol): - """CH=C=C""" - query = Chem.MolFromSmarts('[C;H1;!R]=[C;H0;!R]=[C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_217(mol): - """OP(=S)O""" - query = Chem.MolFromSmarts('[O;H0][P;H0](=[S;H0])[O;H0]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_218(mol): # todo ? - """R""" - query = Chem.MolFromSmarts('[*]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_219(mol): - """CF2cyc""" - query = Chem.MolFromSmarts('[F][C;H0;R][F]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def f_220(mol): - """CFcyc""" - query = Chem.MolFromSmarts('[F][C;H0;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -# s order -def s_001(mol): - """(CH3)2CH""" - query = Chem.MolFromSmarts('[C;H3;!R;D1][C;H1;!R;D3][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_002(mol): - """(CH3)3C""" - query = Chem.MolFromSmarts('[C;H3;!R;D1][C;H0;!R;D4]([C;H3;!R;D1])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_003(mol): # todo 能不能把多余的原子去掉? 好像没有必要 - """CH(CH3)CH(CH3)""" - query = Chem.MolFromSmarts('[*]-[C;H1;!R;D3]([C;H3;!R;D1])[C;H1;!R;D3]([C;H3;!R;D1])-[*]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_004(mol): # todo 能不能把多余的原子去掉 - """CH(CH3)C(CH3)2""" - query = Chem.MolFromSmarts('[*]-[C;H1;!R;D3]([C;H3;!R;D1])[C;H0;!R;D4]([*])([C;H3;!R;D1])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_005(mol): # todo 能不能把多余的原子去掉 - """C(CH3)2C(CH3)2""" - query = Chem.MolFromSmarts('[*]-[C;H0;!R;D4]([C;H3;!R;D1])([C;H3;!R;D1])[C;H0;!R;D4]([*])([C;H3;!R;D1])[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_006(mol): - """CHn=CHm-CHp=CHk k,m,n,p=0,1,2""" - query = Chem.MolFromSmarts('[C;!R]=[C;!R]-[C;!R]=[C;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_007(mol): - """CH3-CHm=CHn m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;!R]=[C;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_008(mol): - """CH2-CHm=CHn m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H2;!R;D2]-[C;!R]=[C;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_009(mol): - """CHp-CHm=CHn p=0,1 m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;!H2;!R]-[C;!R]=[C;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_010(mol): - """CHCHO or CCHO""" - query1 = Chem.MolFromSmarts('[C;H1;!R][C;H1;!R;D2]=[O;H0;D1]') - query2 = Chem.MolFromSmarts('[C;H0;!R][C;H1;!R;D2]=[O;H0;D1]') - match_list = mol.GetSubstructMatches(query1) + mol.GetSubstructMatches(query2) - return len(match_list), match_list - - -def s_011(mol): - """CH3COCH2""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_012(mol): - """CH3COCH or CH3COC""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H1,H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_013(mol): - """CHCOOH or CCOOH""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H1,H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_014(mol): - """CH3COOCH or CH3COOC""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H0;!R;D2]-[C;H1,H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_015(mol): - """CO-O-CO""" - query = Chem.MolFromSmarts('[O;H0;!R;D1]=[C;H0;!R;D3]-[O;H0;!R;D2]-[C;H0;!R;D3]=[O;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_016(mol): - """CHOH""" - query = Chem.MolFromSmarts('[C;H1;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_017(mol): - """COH""" - query = Chem.MolFromSmarts('[C;H0;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_018(mol): - """CH3COCHnOH n=0,1,2""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])[C;H0,H1,H2;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_019(mol): - """NCCHOH or NCCOH""" - query = Chem.MolFromSmarts('[N;H0;!R;D1]#[C;H0;!R;D2][C;H0,H1;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_020(mol): - """OH-CHn-COO n=0,1,2""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]-[C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_021(mol): - """CHm(OH)CHn(OH) m,n=0,1,2""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]-[C;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_022(mol): - """CHm(OH)CHn(NHp) m,n,p=0,1,2""" - query = Chem.MolFromSmarts('[O;H1;!R;D1]-[C;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[N;H0,H1,H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_023(mol): - """CHm(NH2)CHn(NH2) m,n=0,1,2""" - query = Chem.MolFromSmarts('[N;H2;!R;D1]-[C;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_024(mol): - """CHm(NH)CHn(NH2) m,n=0,1,2""" - query = Chem.MolFromSmarts('[N;H;!R;D2]-[C;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_025(mol): - """H2NCOCHnCHmCONH2 m,n=0,1,2""" - query = Chem.MolFromSmarts( - '[N;H2;!R;D1]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H0,H1,H2;!R][C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_026(mol): - """CHm(NHn)-COOH m,n=0,1,2""" - query = Chem.MolFromSmarts('[N;H0,H1,H2;!R]-[C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_027(mol): - """HOOC-CHn-COOH n=1,2""" - query = Chem.MolFromSmarts( - '[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_028(mol): - """HOOC-CHn-CHm-COOH m,n=1,2""" - query = Chem.MolFromSmarts( - '[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_029(mol): - """HO-CHn-COOH n=0,1,2""" - query = Chem.MolFromSmarts('[O;H1;!R;D1][C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_030(mol): - """NH2-CHn-CHm-COOH m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([N;H2;!R;D1])-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_031(mol): - """CH3-O-CHn-COOH n=1,2""" - query = Chem.MolFromSmarts('[C;H3;!R;D1]-[O;H0;!R;D2]-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_032(mol): - """HS-CH-COOH""" - query = Chem.MolFromSmarts('[S;H1;!R;D1][C;H1;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_033(mol): - """HS-CHn-CHm-COOH m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([S;H1;!R;D1])-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_034(mol): - """NC-CHn-CHm-CN m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([C;H0;!R;D2]#[N;H0;!R;D1])-[C;H1,H2;!R]([C;H0;!R;D2]#[N;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_035(mol): - """OH-CHn-CHm-CN m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([O;H1;!R;D1])-[C;H1,H2;!R]([C;H0;!R;D2]#[N;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_036(mol): - """HS-CHn-CHm-SH m,n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([S;H1;!R;D1])-[C;H1,H2;!R]([S;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_037(mol): - """COO-CHn-CHm-OOC m,n=1,2""" - query = Chem.MolFromSmarts( - '[C;H1,H2;!R]([O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1]))-[C;H1,H2;!R]([O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_038(mol): - """OOC-CHn-CHm-COO m,n=1,2""" - query = Chem.MolFromSmarts( - '[C;H1,H2;!R]([C;H0;D3;!R](=[O;H0;!R;D1])[O;H0;!R;D2])-[C;H1,H2;!R]([C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_039(mol): - """NC-CHn-COO n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([C;H0;!R;D2]#[N;H0;!R;D1])([C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_040(mol): - """COCHnCOO n=1,2""" - query = Chem.MolFromSmarts('[C;H1,H2;!R]([C;H0;!R;D3](=[O;H0;!R;D1]))([C;H0;D3;!R](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_041(mol): - """CHm-O-CHn=CHp m,n,p=0,1,2,3""" - query = Chem.MolFromSmarts('[C;H0,H1,H2,H3;!R]-[O;H0;!R;D2]-[C;H0,H1,H2,H3;!R]=[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_042(mol): - """CHm=CHn-F m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_043(mol): - """CHm=CHn-Br m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-Br') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_044(mol): - """CHm=CHn-I m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-I') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_045(mol): - """CHm=CHn-Cl m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_046(mol): - """CHm=CHn-CN m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_047(mol): - """CHm=CHn-COO-CHp m,n,p=0,1,2,3""" - query = Chem.MolFromSmarts( - '[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-[C;H0;!R](=[O;H0;D1;!R])[O;H0;!R;D2]-[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_048(mol): - """CHm=CHn-CHO m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_049(mol): - """CHm=CHn-COOH m,n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_050(mol): - """aC-CHn-X n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[F,Cl,Br,I]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_051(mol): - """aC-CHn-NHm n=1,2 m=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[N;H0,H1,H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_052(mol): - """aC-CHn-O- n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_053(mol): - """aC-CHn-OH n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_054(mol): - """aC-CHn-CN n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_055(mol): - """aC-CHn-CHO n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_056(mol): - """aC-CHn-SH n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_057(mol): - """aC-CHn-COOH n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_058(mol): - """aC-CHn-CO- n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_059(mol): - """aC-CHn-S- n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_060(mol): - """aC-CHn-OOC-H n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]([O;H0;!R;D2][C;H1;!R;D2](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_061(mol): - """aC-CHn-NO2 n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_062(mol): - """aC-CHn-CONH2 n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_063(mol): - """aC-CHn-OOC n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]([O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_064(mol): - """aC-CHn-COO n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H1,H2;!R]([C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_065(mol): - """aC-SO2-OH n=1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[S;H0;!R;D4](=[O;H0;!R;D1])(=[O;H0;!R;D1])[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_066(mol): - """aC-CH(CH3)2""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H1;!R;D3]([C;H3;!R;D1])([C;H3;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_067(mol): - """aC-C(CH3)3""" - query = Chem.MolFromSmarts('[c;H0;R;D3][C;H0;!R;D4]([C;H3;!R;D1])([C;H3;!R;D1])([C;H3;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_068(mol): - """aC-CF3""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D4](F)(F)F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_069(mol): # todo 芳香性 - """(CHn=C)(cyclic)-CHO n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_070(mol): # todo 芳香性 - """(CHn=C)(cyclic)-COO-CHm m,n=0,1,2,3""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H0;!R;D2]-[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_071(mol): # todo 芳香性 - """(CHn=C)(cyclic)-CO- n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_072(mol): # todo 芳香性 - """(CHn=C)cyc-CH3, n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_073(mol): # todo 芳香性 - """(CHn=C)cyc-CH2, n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_074(mol): # todo 芳香性 - """(CHn=C)(cyclic)-CN n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_075(mol): # todo 芳香性 - """(CHn=C)(cyclic)-Cl n=0,1,2""" - query = Chem.MolFromSmarts('[C;H0,H1,H2;R]=[C;H0;R;D3]-Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_076(mol): - """CHcyc-CH3""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_077(mol): - """CHcyc-CH2""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_078(mol): - """CHcyc-CH""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H1;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_079(mol): - """CHcyc-C""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H0;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_080(mol): - """CHcyc-CH=CHn n=1,2""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H1;!R;D2]=[C;H1,H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_081(mol): - """CHcyc-C=CHn n=1,2""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H0;!R;D3]=[C;H1,H2;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_082(mol): - """CHcyc-Cl""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-Cl') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_083(mol): - """CHcyc-F""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-F') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_084(mol): - """CHcyc-OH""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_085(mol): - """CHcyc-NH2""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[N;H2;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_086(mol): - """CHcyc-NH-CHn n=0,1,2,3""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[N;H1;!R;D2]-[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_087(mol): - """CHcyc-N-CHn n=0,1,2,3""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[N;H0;!R;D3]-[C;H0,H1,H2,H3;!R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_088(mol): - """CHcyc-SH""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[S;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_089(mol): - """CHcyc-CN""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[C;H0;!R;D2]#[N;H0;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_090(mol): - """CHcyc-COOH""" - query = Chem.MolFromSmarts('[C;H1;R;D3](-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_091(mol): - """CHcyc-CO-""" - query = Chem.MolFromSmarts('[C;H1;R;D3][C;H0;!R;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_092(mol): - """CHcyc-NO2""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[N;+]([O;-])=[O]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_093(mol): - """CHcyc-S-""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[S;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_094(mol): - """CHcyc-CHO""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[C;H1;!R;D2](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_095(mol): - """CHcyc-O-""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-[O;H0;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_096(mol): - """CHcyc-OOCH""" - query = Chem.MolFromSmarts('[C;H1;R;D3]([O;H0;!R;D2][C;H1;!R;D2](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_097(mol): - """CHcyc-COO""" - query = Chem.MolFromSmarts('[C;H1;R;D3]([C;H0;!R;D3](=[O;H0;!R;D1])[O;H0;!R;D2])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_098(mol): - """CHcyc-OOC""" - query = Chem.MolFromSmarts('[C;H1;R;D3]([O;H0;!R;D2][C;H0;!R;D3](=[O;H0;!R;D1]))') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_099(mol): - """Ccyc-CH3 - 环上的C要求D4是因为前面有s72 - """ - query = Chem.MolFromSmarts('[C;H0;R;D4][C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_100(mol): - """Ccyc-CH2 - 环上的C要求D4是因为前面有s73 - """ - query = Chem.MolFromSmarts('[C;H0;R;D4][C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_101(mol): - """Ccyc-OH""" - query = Chem.MolFromSmarts('[C;H0;R;D4][O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_102(mol): - """>Ncyc-CH3""" - query = Chem.MolFromSmarts('[N,n;H0;D3;R]-[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_103(mol): - """>Ncyc-CH2""" - query = Chem.MolFromSmarts('[N,n;H0;D3;R]-[C;H2;!R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_104(mol): - """AROMRINGs1s2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_105(mol): - """AROMRINGs1s3""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_106(mol): - """AROMRINGs1s4""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_107(mol): - """AROMRINGs1s2s3""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_108(mol): - """AROMRINGs1s2s4""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_109(mol): - """AROMRINGs1s3s5""" - query = Chem.MolFromSmarts('[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_110(mol): - """AROMRINGs1s2s3s4""" - query = Chem.MolFromSmarts( - '[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_111(mol): - """AROMRINGs1s2s3s5""" - query = Chem.MolFromSmarts( - '[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_112(mol): - """AROMRINGs1s2s4s5""" - query = Chem.MolFromSmarts( - '[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_113(mol): - """PYRIDINEs2""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_114(mol): - """PYRIDINEs3""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_115(mol): - """PYRIDINEs4""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_116(mol): - """PYRIDINEs2s3""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_117(mol): - """PYRIDINEs2s4""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_118(mol): - """PYRIDINEs2s5""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_119(mol): - """PYRIDINEs2s6""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_120(mol): - """PYRIDINEs3s4""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_121(mol): - """PYRIDINEs3s5""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_122(mol): - """PYRIDINEs2s3s6""" - query = Chem.MolFromSmarts('[n;H0;R;D2][c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2][c;H1;R;D2][c;H0;R;D3]([!a;!R])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_123(mol): - """(CHn=CHm)cyc-COOH""" - query = Chem.MolFromSmarts('[C;R]@&=[C;R]!@&-[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H1;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_124(mol): - """AROMRINGs1s2s3s4s5""" - # query = Chem.MolFromSmarts('[c]1[c](!@&-[*])[c](!@&-[*])[c](!@&-[*])[c](!@&-[*])[c]1(!@&-[*])') - query = Chem.MolFromSmarts( - '[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H0;R;D3]([!a;!R])[c;H1;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_125(mol): - """aC-NHCOCH2N""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[N;H1]-[C;H0](=[O;H0;D1])[C;H2][N]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_126(mol): - """(N=C)cyc-CH3""" - query = Chem.MolFromSmarts('[N;R]@&=[C;R]!@&-[C;H3;!R;D1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_127(mol): - """aC-CONH(CH2)2N""" - query = Chem.MolFromSmarts('[c]!@&-[C](=[O;H0;D1])[N;H1][C;H2][C;H2][N]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_128(mol): # todo 原文128、129、130完全一样,不知道怎么回事 - """aC-SO2NHn n=0,1,2""" - query = Chem.MolFromSmarts('[c]!@&-[S](=[O;H0;D1])(=[O;H0;D1])[N;H0,H1,H2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_129(mol): # todo 原文128、129、130完全一样,不知道怎么回事 - """aC-SO2NHn n=0,1,2""" - query = Chem.MolFromSmarts('[c]!@&-[S](=[O;H0;D1])(=[O;H0;D1])[N;H0,H1,H2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def s_130(mol): # todo 原文128、129、130完全一样,不知道怎么回事 - """aC-SO2NHn n=0,1,2""" - query = Chem.MolFromSmarts('[c]!@&-[S](=[O;H0;D1])(=[O;H0;D1])[N;H0,H1,H2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -# t order -def t_001(mol): - """HOOC-(CHn)m-COOH m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[C;H0;!R;D3](=[O;H0;!R;D1])(-[O;H1;!R;D1]){}-[C;H0;!R;D3](=[O;H0;!R;D1])(-[O;H1;!R;D1])'.format( - '-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_002(mol): - """NHn-(CHn)m-COOH m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[N;H0,H1,H2;!R]{}-[C;H0;!R;D3](=[O;H0;!R;D1])(-[O;H1;!R;D1])'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_003(mol): - """NH2-(CHn)m-OH m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[N;H2;!R;D1]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_004(mol): - """OH-(CHn)m-OH m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[O;H1;!R;D1]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_005(mol): - """OH-(CHp)k-O-(CHn)m-OH m,k>0;p,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - for j in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[O;H1;!R;D1]{}-[O;H0;D2;!R]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i, '-[C;H0,H1,H2;!R]' * j)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_006(mol): - """OH-(CHp)k-S-(CHn)m-OH m,k>0;p,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - for j in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[O;H1;!R;D1]{}-[S;H0;D2;!R]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i, '-[C;H0,H1,H2;!R]' * j)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_007(mol): - """OH-(CHp)k-NHx-(CHn)m-OH m,k>0;x,p,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - for j in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[O;H1;!R;D1]{}-[N;H0,H1,H2;!R]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i, '-[C;H0,H1,H2;!R]' * j)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_008(mol): - """CHp-O-(CHn)m-OH m>2;p,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[C;H0,H1,H2;!R][O;H0;!R;D2]{}-[O;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_009(mol): - """NH2-(CHn)m-NH2 m>2,n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[N;H2;!R;D1]{}-[N;H2;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_010(mol): - """NHk-(CHn)m-NH2 m>2;n=0,1,2;k=0,1""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[N;H0,H1;!R]{}-[N;H2;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_011(mol): - """SH-(CHn)m-SH m>2;n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[S;H1;!R;D1]{}-[S;H1;!R;D1]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_012(mol): - """CN-(CHn)m-CN m>2;n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[C;H0;!R;D2](#[N;H0;!R;D1]){}-[C;H0;!R;D2](#[N;H0;!R;D1])'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_013(mol): - """COO-(CHn)m-OOC m>2;n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(3, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[C;H0;!R;D3](=[O;H0;!R;D1])-[O;H0;!R;D2]{}-[O;H0;!R;D2]-[C;H0;!R;D3](=[O;H0;!R;D1])'.format( - '-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_014(mol): - """aC-(CHn=CHm)cyc (fused rings) m,n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@&-[C;H1,H2;R]@&=[C;H1,H2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_015(mol): - """aC-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]!@&-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_016(mol): - """aC-CHncyc (different rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]!@&-[C;H0,H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_017(mol): - """aC-CHncyc (fused rings) n=0,1,2原文写的0,1应该是不对的""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@&-[C;H0,H1,H2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_018(mol): - """aC-(CHn)m-aC m>1 n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(2, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[c;H0;R;D3]{}-[c;H0;R;D3]'.format('-[C;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_019(mol): - """aC-(CHn)m-CHcyc m>1 n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[c;H0;R;D3]{}-[C;H1;R;D3]'.format('-[C;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_020(mol): - """CHcyc-CHcyc (different rings)""" - query = Chem.MolFromSmarts('[C;H1;R;D3]-&!@[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_021(mol): - """CHcyc-(CHn)m-CHcyc (different rings) m>0, n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts('[C;H1;R;D3]{}!@&-[C;H1;R;D3]'.format('!@&-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_022(mol): - """CH multiring""" - query = Chem.MolFromSmarts('[C;H1;!R1&R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_023(mol): - """C multiring""" - query = Chem.MolFromSmarts('[C;H0;!R1&R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_024(mol): - """aC-CHm-aC (different rings) m=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R1]-[C;H0,H1,H2;!R]-[c;H0;R1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_025(mol): - """aC-(CHm=CHn)-aC (different rings) m,n=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]-[C;H0,H1,H2;!R]=[C;H0,H1,H2;!R]-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_026(mol): # todo 芳香性 - """(CHm=C)cyc-CH=CH-(C=CHn)cyc (different rings) m,n没有限制""" - query = Chem.MolFromSmarts('[C;R]=[C;H0;R]-[C;H1;!R]=[C;H1;!R]-[C;H0;R]=[C;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_027(mol): # todo 芳香性 - """(CHm=C)cyc-CHp-(C=CHn)cyc (different rings) m,n,p没有限制""" - query = Chem.MolFromSmarts('[C;R]=[C;H0;R]-[C;!R]-[C;H0;R]=[C;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_028(mol): - """aC-CO-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_029(mol): - """aC-CHm-CO-aC (different rings) m=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0,H1,H2;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_030(mol): # todo 芳香性 - """aC-CO-(C=CHn)cyc (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H0;R;D3]=[C;H0,H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_031(mol): - """aC-CO-CO-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H0;!R;D3](=[O;H0;!R;D1])-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_032(mol): - """aC-COcyc (fused rings)""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@&-[C;R;H0;D3](=[O;H0;!R;D1])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_033(mol): - """aC-CO-(CHn)m-CO-aC (different rings) m>0;n=0,1,2""" - not_in_ring_atoms = [atom for atom in mol.GetAtoms() if not atom.IsInRing()] - match_list = () - for i in range(1, len(not_in_ring_atoms) + 1): - query = Chem.MolFromSmarts( - '[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1]){}-[C;H0;!R;D3](=[O;H0;!R;D1])-[c;H0;R;D3]'.format('-[C;H0,H1,H2;!R]' * i)) - match_list += mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_034(mol): - """aC-CO-CHn,cyc (different rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[C;H0,H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_035(mol): - """aC-CO-NHn-aC (different rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0,H1;!R]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_036(mol): - """aC-NHnCONHm-aC (different rings) m,n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H0,H1;!R]-[C;H0;!R;D3](=[O;H0;!R;D1])-[N;H0,H1;!R]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_037(mol): - """aC-CO-Ncyc (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[C;H0;!R;D3](=[O;H0;!R;D1])!@&-[N;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_038(mol): - """aC-Scyc (fused rings)""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@[S,s;H0;D2;R1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_039(mol): - """aC-S-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R1;D3]!@&-[S;H0;D2;!R]!@&-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_040(mol): # todo ? - """aC-POn-aC (different rings) n=0,1,2,3,4""" - query0 = Chem.MolFromSmarts('[c;H0;R1;D3]-[P;H0,H1;!R]-[c;H0;R1;D3]') - query1 = Chem.MolFromSmarts('[c;H0;R1;D3]-[O;H0;D2;!R]-[P;!R]-[O;H0;D2;!R]-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query0) + mol.GetSubstructMatches(query1) - return len(match_list), match_list - - -def t_041(mol): - """aC-SOn-aC (different rings) n=1,2,3,4""" - query1 = Chem.MolFromSmarts('[c;H0;R1;D3]-[S;H0;D3;!R](=[O;!R;H0;D1])-[c;H0;R1;D3]') - query2 = Chem.MolFromSmarts('[c;H0;R1;D3]-[S;H0;D4;!R](=[O;!R;H0;D1])(=[O;!R;H0;D1])-[c;H0;R1;D3]') - query3 = Chem.MolFromSmarts('[c;H0;R1;D3]-[O;H0;D2;!R]-[S;H0;D3;!R](=[O;!R;H0;D1])-[O;H0;D2;!R]-[c;H0;R1;D3]') - query4 = Chem.MolFromSmarts( - '[c;H0;R1;D3]-[O;H0;D2;!R]-[S;H0;D4;!R](=[O;!R;H0;D1])(=[O;!R;H0;D1])-[O;H0;D2;!R]-[c;H0;R1;D3]') - match_list = mol.GetSubstructMatches(query1) + mol.GetSubstructMatches(query2) + mol.GetSubstructMatches( - query3) + mol.GetSubstructMatches(query4) - return len(match_list), match_list - - -def t_042(mol): - """aC-NHncyc (fused rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@[N,n;H0,H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_043(mol): - """aC-NH-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[N;H1;!R;D2]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_044(mol): - """aC-(C=N)cyc (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[c,C;H0;R;D3]=,:[n,N;H0;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_045(mol): - """aC-(N=CHn)cyc (fused rings) n=0,1""" - # query = Chem.MolFromSmarts('[c][c;H0;R2]([c])-[N;H0;D2;R1]=[C;H0,H1;R]') - query = Chem.MolFromSmarts('[c;H0;R;D3]@[n,N;H0;R;D2]@,=,:[c,C;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_046(mol): - """aC-(N=CHn)cyc (fused rings) n=0,1""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@,=,:[c,C;H0,H1;R]@,=,:[n,N;H0;D2;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_047(mol): - """aC-O-CHn-aC (different rings) n=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@,-[O;H0;!R;D2]!@,-[C;H0,H1,H2;!R]!@,-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_048(mol): - """aC-O-aC (different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[O;H0;!R;D2]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_049(mol): - """aC-CHn-O-CHm-aC (different rings) m,n=0,1,2""" - query = Chem.MolFromSmarts('[c;H0;R;D3]-[C;H0,H1,H2;!R]-[O;H0;!R;D2]-[C;H0,H1,H2;!R]-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_050(mol): - """aC-Ocyc (fused rings)""" - query = Chem.MolFromSmarts('[c;H0;R2;D3]@[O,o;H0;R;D2]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_051(mol): - """AROMFUSED[2]""" - query = Chem.MolFromSmarts('[c]:[c;R2](:[c]):[c]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_052(mol): - """AROMFUSED[2]s1""" - query = Chem.MolFromSmarts('[c;R2]([c;H1]):[c;R2]([c;H1])c([C,O,N,P,S,F,Cl,Br,I])[c;H1][c;H1][c;H1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_053(mol): - """AROMFUSED[2]s2""" - query = Chem.MolFromSmarts('[*]!@[c]1[c;H1][c;R2]([c])[c;R2]([c])[c;H1][c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_054(mol): - """AROMFUSED[2]s2s3""" - query = Chem.MolFromSmarts('[*]!@[c]1[c](!@[*])[c;H1][c;R2]([c])[c;R2]([c])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_055(mol): - """AROMFUSED[2]s1s4""" - query = Chem.MolFromSmarts('[c;H1]1[c](!@[*])[c;R2]([c])[c;R2]([c])[c](!@[*])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_056(mol): - """AROMFUSED[2]s1s2""" - query = Chem.MolFromSmarts('[c;H1]1[c](!@[*])[c](!@[*])[c;R2]([c])[c;R2]([c])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_057(mol): - """AROMFUSED[2]s1s3""" - query = Chem.MolFromSmarts('[c;H1]1[c](!@[*])[c;H1][c;R2]([c])[c;R2]([c])[c](!@[*])1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_058(mol): - """AROMFUSED[3]""" - query = Chem.MolFromSmarts('[c][c;R2]1[c;R3]([c])[c;R2]([c])ccc1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_059(mol): - """AROMFUSED[4a]""" - query = Chem.MolFromSmarts('[c;H1][c;R2]1[c;R2]([c])[c;H1][c;R2]([c])[c;R2]([c])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_060(mol): - """AROMFUSED[4a]s1""" - query = Chem.MolFromSmarts('[*]!@[c]1[c;R2]([c])[c;R2]([c])[c;H1][c;R2]([c])[c;R2]1([c])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_061(mol): - """AROMFUSED[4a]s1s4""" - query = Chem.MolFromSmarts('[*]!@[c]1[c;R2]([c])[c;R2]([c])[c](!@[*])[c;R2]([c])[c;R2]1([c])') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_062(mol): - """AROMFUSED[4p]""" - query = Chem.MolFromSmarts('[c;H1]1[c;!R1]([c])[c;!R1]([c])[c;!R1]([c])[c;!R1]([c])[c;H1]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_063(mol): - """AROMFUSED[4p]s3s4""" - query = Chem.MolFromSmarts('[*]!@[c]1[c;!R1]([c])[c;!R1]([c])[c;!R1]([c])[c;!R1]([c])[c]1!@[*]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_064(mol): - """PYRIDINE.FUSED[2]""" - query = Chem.MolFromSmarts('[n;H0;R1;D2]1[c;H1;R1;D2][c;H1;R1;D2][c;H1;R1;D2][c;H0;R2;D3](a)[c;H0;R2;D3]1(a)') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_065(mol): - """PYRIDINE.FUSED[2-iso]""" - query = Chem.MolFromSmarts('[n;H0;R1;D2]1[c;H1;R1;D2][c;H1;R1;D2][c;H0;R2;D3](a)[c;H0;R2;D3](a)[c;H1;R1;D2]1') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_066(mol): - """PYRIDINE.FUSED[4]""" - query = Chem.MolFromSmarts('[n;H0;R1;D2]1[c;H0;R2;D3](a)[c;H0;R2;D3](a)[c;H1;R1;D2][c;H0;R2;D3](a)[c;H0;R2;D3]1(a)') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_067(mol): - """aC-N-CHcyc(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[N]!@&-[C;H1;R]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_068(mol): - """N multiring""" - query = Chem.MolFromSmarts('[N;R&!R1]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_069(mol): - """Ncyc-(CH2)3-Ncyc(different rings)""" - query = Chem.MolFromSmarts('[N;H0;R;D3]!@&-[C;H2][C;H2][C;H2]!@&-[N;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_070(mol): - """aC-COCH2CH2-aC(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[C;H0](=[O;H0;!R;D1])[C;H2][C;H2]!@&-[c;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_071(mol): - """aC-O-(CH2)2-Ncyc(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[O;H0;D2]-[C;H2][C;H2]!@&-[N;H0;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_072(mol): - """aC-CH(OH)(CH2)2-CHcyc(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[C;H1]([O;H1;!R;D1])-[C;H2][C;H2]!@&-[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_073(mol): - """Ncyc-(CH2)2-CHcyc(different rings)""" - query = Chem.MolFromSmarts('[N;H0;R;D3]!@&-[C;H2][C;H2]!@&-[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -def t_074(mol): - """aC-CONHCH2-CHcyc(different rings)""" - query = Chem.MolFromSmarts('[c;H0;R;D3]!@&-[C;H0;D3](=[O;H0;!R;D1])[N;H1;D2][C;H2]!@&-[C;H1;R;D3]') - match_list = mol.GetSubstructMatches(query) - return len(match_list), match_list - - -class Counter: - def __init__(self): - - self.init_result = { - 'f_001': 0, 'f_002': 0, 'f_003': 0, 'f_004': 0, 'f_005': 0, 'f_006': 0, 'f_007': 0, 'f_008': 0, 'f_009': 0, - 'f_010': 0, 'f_011': 0, 'f_012': 0, 'f_013': 0, 'f_014': 0, 'f_015': 0, 'f_016': 0, 'f_017': 0, 'f_018': 0, - 'f_019': 0, 'f_020': 0, 'f_021': 0, 'f_022': 0, 'f_023': 0, 'f_024': 0, 'f_025': 0, 'f_026': 0, 'f_027': 0, - 'f_028': 0, 'f_029': 0, 'f_030': 0, 'f_031': 0, 'f_032': 0, 'f_033': 0, 'f_034': 0, 'f_035': 0, 'f_036': 0, - 'f_037': 0, 'f_038': 0, 'f_039': 0, 'f_040': 0, 'f_041': 0, 'f_042': 0, 'f_043': 0, 'f_044': 0, 'f_045': 0, - 'f_046': 0, 'f_047': 0, 'f_048': 0, 'f_049': 0, 'f_050': 0, 'f_051': 0, 'f_052': 0, 'f_053': 0, 'f_054': 0, - 'f_055': 0, 'f_056': 0, 'f_057': 0, 'f_058': 0, 'f_059': 0, 'f_060': 0, 'f_061': 0, 'f_062': 0, 'f_063': 0, - 'f_064': 0, 'f_065': 0, 'f_066': 0, 'f_067': 0, 'f_068': 0, 'f_069': 0, 'f_070': 0, 'f_071': 0, 'f_072': 0, - 'f_073': 0, 'f_074': 0, 'f_075': 0, 'f_076': 0, 'f_077': 0, 'f_078': 0, 'f_079': 0, 'f_080': 0, 'f_081': 0, - 'f_082': 0, 'f_083': 0, 'f_084': 0, 'f_085': 0, 'f_086': 0, 'f_087': 0, 'f_088': 0, 'f_089': 0, 'f_090': 0, - 'f_091': 0, 'f_092': 0, 'f_093': 0, 'f_094': 0, 'f_095': 0, 'f_096': 0, 'f_097': 0, 'f_098': 0, 'f_099': 0, - 'f_100': 0, 'f_101': 0, 'f_102': 0, 'f_103': 0, 'f_104': 0, 'f_105': 0, 'f_106': 0, 'f_107': 0, 'f_108': 0, - 'f_109': 0, 'f_110': 0, 'f_111': 0, 'f_112': 0, 'f_113': 0, 'f_114': 0, 'f_115': 0, 'f_116': 0, 'f_117': 0, - 'f_118': 0, 'f_119': 0, 'f_120': 0, 'f_121': 0, 'f_122': 0, 'f_123': 0, 'f_124': 0, 'f_125': 0, 'f_126': 0, - 'f_127': 0, 'f_128': 0, 'f_129': 0, 'f_130': 0, 'f_131': 0, 'f_132': 0, 'f_133': 0, 'f_134': 0, 'f_135': 0, - 'f_136': 0, 'f_137': 0, 'f_138': 0, 'f_139': 0, 'f_140': 0, 'f_141': 0, 'f_142': 0, 'f_143': 0, 'f_144': 0, - 'f_145': 0, 'f_146': 0, 'f_147': 0, 'f_148': 0, 'f_149': 0, 'f_150': 0, 'f_151': 0, 'f_152': 0, 'f_153': 0, - 'f_154': 0, 'f_155': 0, 'f_156': 0, 'f_157': 0, 'f_158': 0, 'f_159': 0, 'f_160': 0, 'f_161': 0, 'f_162': 0, - 'f_163': 0, 'f_164': 0, 'f_165': 0, 'f_166': 0, 'f_167': 0, 'f_168': 0, 'f_169': 0, 'f_170': 0, 'f_171': 0, - 'f_172': 0, 'f_173': 0, 'f_174': 0, 'f_175': 0, 'f_176': 0, 'f_177': 0, 'f_178': 0, 'f_179': 0, 'f_180': 0, - 'f_181': 0, 'f_182': 0, 'f_183': 0, 'f_184': 0, 'f_185': 0, 'f_186': 0, 'f_187': 0, 'f_188': 0, 'f_189': 0, - 'f_190': 0, 'f_191': 0, 'f_192': 0, 'f_193': 0, 'f_194': 0, 'f_195': 0, 'f_196': 0, 'f_197': 0, 'f_198': 0, - 'f_199': 0, 'f_200': 0, 'f_201': 0, 'f_202': 0, 'f_203': 0, 'f_204': 0, 'f_205': 0, 'f_206': 0, 'f_207': 0, - 'f_208': 0, 'f_209': 0, 'f_210': 0, 'f_211': 0, 'f_212': 0, 'f_213': 0, 'f_214': 0, 'f_215': 0, 'f_216': 0, - 'f_217': 0, 'f_218': 0, 'f_219': 0, 'f_220': 0, - - 's_001': 0, 's_002': 0, 's_003': 0, 's_004': 0, 's_005': 0, 's_006': 0, 's_007': 0, 's_008': 0, 's_009': 0, - 's_010': 0, 's_011': 0, 's_012': 0, 's_013': 0, 's_014': 0, 's_015': 0, 's_016': 0, 's_017': 0, 's_018': 0, - 's_019': 0, 's_020': 0, 's_021': 0, 's_022': 0, 's_023': 0, 's_024': 0, 's_025': 0, 's_026': 0, 's_027': 0, - 's_028': 0, 's_029': 0, 's_030': 0, 's_031': 0, 's_032': 0, 's_033': 0, 's_034': 0, 's_035': 0, 's_036': 0, - 's_037': 0, 's_038': 0, 's_039': 0, 's_040': 0, 's_041': 0, 's_042': 0, 's_043': 0, 's_044': 0, 's_045': 0, - 's_046': 0, 's_047': 0, 's_048': 0, 's_049': 0, 's_050': 0, 's_051': 0, 's_052': 0, 's_053': 0, 's_054': 0, - 's_055': 0, 's_056': 0, 's_057': 0, 's_058': 0, 's_059': 0, 's_060': 0, 's_061': 0, 's_062': 0, 's_063': 0, - 's_064': 0, 's_065': 0, 's_066': 0, 's_067': 0, 's_068': 0, 's_069': 0, 's_070': 0, 's_071': 0, 's_072': 0, - 's_073': 0, 's_074': 0, 's_075': 0, 's_076': 0, 's_077': 0, 's_078': 0, 's_079': 0, 's_080': 0, 's_081': 0, - 's_082': 0, 's_083': 0, 's_084': 0, 's_085': 0, 's_086': 0, 's_087': 0, 's_088': 0, 's_089': 0, 's_090': 0, - 's_091': 0, 's_092': 0, 's_093': 0, 's_094': 0, 's_095': 0, 's_096': 0, 's_097': 0, 's_098': 0, 's_099': 0, - 's_100': 0, 's_101': 0, 's_102': 0, 's_103': 0, 's_104': 0, 's_105': 0, 's_106': 0, 's_107': 0, 's_108': 0, - 's_109': 0, 's_110': 0, 's_111': 0, 's_112': 0, 's_113': 0, 's_114': 0, 's_115': 0, 's_116': 0, 's_117': 0, - 's_118': 0, 's_119': 0, 's_120': 0, 's_121': 0, 's_122': 0, 's_123': 0, 's_124': 0, 's_125': 0, 's_126': 0, - 's_127': 0, 's_128': 0, 's_129': 0, 's_130': 0, - - 't_001': 0, 't_002': 0, 't_003': 0, 't_004': 0, 't_005': 0, 't_006': 0, 't_007': 0, 't_008': 0, 't_009': 0, - 't_010': 0, 't_011': 0, 't_012': 0, 't_013': 0, 't_014': 0, 't_015': 0, 't_016': 0, 't_017': 0, 't_018': 0, - 't_019': 0, 't_020': 0, 't_021': 0, 't_022': 0, 't_023': 0, 't_024': 0, 't_025': 0, 't_026': 0, 't_027': 0, - 't_028': 0, 't_029': 0, 't_030': 0, 't_031': 0, 't_032': 0, 't_033': 0, 't_034': 0, 't_035': 0, 't_036': 0, - 't_037': 0, 't_038': 0, 't_039': 0, 't_040': 0, 't_041': 0, 't_042': 0, 't_043': 0, 't_044': 0, 't_045': 0, - 't_046': 0, 't_047': 0, 't_048': 0, 't_049': 0, 't_050': 0, 't_051': 0, 't_052': 0, 't_053': 0, 't_054': 0, - 't_055': 0, 't_056': 0, 't_057': 0, 't_058': 0, 't_059': 0, 't_060': 0, 't_061': 0, 't_062': 0, 't_063': 0, - 't_064': 0, 't_065': 0, 't_066': 0, 't_067': 0, 't_068': 0, 't_069': 0, 't_070': 0, 't_071': 0, 't_072': 0, - 't_073': 0, 't_074': 0, - } - self.result = None - - self.f_order_group_function = \ - [ - f_001, f_002, f_003, f_004, f_005, f_006, f_007, f_008, f_009, f_010, f_011, f_012, f_013, f_014, f_015, - f_016, f_017, f_018, f_019, f_020, f_021, f_022, f_023, f_024, f_025, f_026, f_027, f_028, f_029, f_030, - f_031, f_032, f_033, f_034, f_035, f_036, f_037, f_038, f_039, f_040, f_041, f_042, f_043, f_044, f_045, - f_046, f_047, f_048, f_049, f_050, f_051, f_052, f_053, f_054, f_055, f_056, f_057, f_058, f_059, f_060, - f_061, f_062, f_063, f_064, f_065, f_066, f_067, f_068, f_069, f_070, f_071, f_072, f_073, f_074, f_075, - f_076, f_077, f_078, f_079, f_080, f_081, f_082, f_083, f_084, f_085, f_086, f_087, f_088, f_089, f_090, - f_091, f_092, f_093, f_094, f_095, f_096, f_097, f_098, f_099, f_100, f_101, f_102, f_103, f_104, f_105, - f_106, f_107, f_108, f_109, f_110, f_111, f_112, f_113, f_114, f_115, f_116, f_117, f_118, f_119, f_120, - f_121, f_122, f_123, f_124, f_125, f_126, f_127, f_128, f_129, f_130, f_131, f_132, f_133, f_134, f_135, - f_136, f_137, f_138, f_139, f_140, f_141, f_142, f_143, f_144, f_145, f_146, f_147, f_148, f_149, f_150, - f_151, f_152, f_153, f_154, f_155, f_156, f_157, f_158, f_159, f_160, f_161, f_162, f_163, f_164, f_165, - f_166, f_167, f_168, f_169, f_170, f_171, f_172, f_173, f_174, f_175, f_176, f_177, f_178, f_179, f_180, - f_181, f_182, f_183, f_184, f_185, f_186, f_187, f_188, f_189, f_190, f_191, f_192, f_193, f_194, f_195, - f_196, f_197, f_198, f_199, f_200, f_201, f_202, f_203, f_204, f_205, f_206, f_207, f_208, f_209, f_210, - f_211, f_212, f_213, f_214, f_215, f_216, f_217, f_218, f_219, f_220, - ] - self.s_order_group_function = \ - [ - s_001, s_002, s_003, s_004, s_005, s_006, s_007, s_008, s_009, s_010, s_011, s_012, s_013, s_014, s_015, - s_016, s_017, s_018, s_019, s_020, s_021, s_022, s_023, s_024, s_025, s_026, s_027, s_028, s_029, s_030, - s_031, s_032, s_033, s_034, s_035, s_036, s_037, s_038, s_039, s_040, s_041, s_042, s_043, s_044, s_045, - s_046, s_047, s_048, s_049, s_050, s_051, s_052, s_053, s_054, s_055, s_056, s_057, s_058, s_059, s_060, - s_061, s_062, s_063, s_064, s_065, s_066, s_067, s_068, s_069, s_070, s_071, s_072, s_073, s_074, s_075, - s_076, s_077, s_078, s_079, s_080, s_081, s_082, s_083, s_084, s_085, s_086, s_087, s_088, s_089, s_090, - s_091, s_092, s_093, s_094, s_095, s_096, s_097, s_098, s_099, s_100, s_101, s_102, s_103, s_104, s_105, - s_106, s_107, s_108, s_109, s_110, s_111, s_112, s_113, s_114, s_115, s_116, s_117, s_118, s_119, s_120, - s_121, s_122, s_123, s_124, s_125, s_126, s_127, s_128, s_129, s_130, - ] - self.t_order_group_function = \ - [ - t_001, t_002, t_003, t_004, t_005, t_006, t_007, t_008, t_009, t_010, t_011, t_012, t_013, t_014, t_015, - t_016, t_017, t_018, t_019, t_020, t_021, t_022, t_023, t_024, t_025, t_026, t_027, t_028, t_029, t_030, - t_031, t_032, t_033, t_034, t_035, t_036, t_037, t_038, t_039, t_040, t_041, t_042, t_043, t_044, t_045, - t_046, t_047, t_048, t_049, t_050, t_051, t_052, t_053, t_054, t_055, t_056, t_057, t_058, t_059, t_060, - t_061, t_062, t_063, t_064, t_065, t_066, t_067, t_068, t_069, t_070, t_071, t_072, t_073, t_074, - ] - group_order_file_path = os.path.join('gp_3x_internal_data', 'group_order.xlsx') - self.f_order_group_function_order = ( - pd.read_excel(group_order_file_path, sheet_name='f')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上,因为python列表里的索引是从0开始的 - self.s_order_group_function_order = ( - pd.read_excel(group_order_file_path, sheet_name='s')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - self.t_order_group_function_order = ( - pd.read_excel(group_order_file_path, sheet_name='t')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - - def count_a_mol(self, mol, clear_mode=False, add_note=False, add_smiles=False): - init_smi = mol - try: - if isinstance(mol, str): - mol = Chem.MolFromSmiles(mol) - self.result = self.init_result.copy() - if add_note: - # self.result['note'] = '' - self.result['note'] = Chem.MolToSmiles(mol) - if add_smiles: - self.result['smiles'] = Chem.MolToSmiles(mol) - self.count_1st_order_groups(mol=mol, add_note=add_note) - self.count_2nd_order_groups(mol=mol) - self.count_3rd_order_groups(mol=mol) - if clear_mode: - # 清爽模式,不显示没有统计到的基团 - self.result = {k: v for k, v in self.result.items() if v} - return self.result - except: - print(f'Error! There is something wrong when counting {init_smi}, please check it.') - return self.init_result.copy() - - def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv', add_note=False, add_smiles=False): # todo mpi 并行? - print('reading the input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplementedError( - 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') - - mol_number = len(smiles_iterator) - print('Done, totally detected {} molecules, start counting...'.format(mol_number)) - count_result_dict_list = [] - for i in tqdm(smiles_iterator): - count_result_dict_list.append(self.count_a_mol(i, add_note=add_note, add_smiles=add_smiles)) - print('Done!') - print('writing to csv...') - result = pd.DataFrame(count_result_dict_list) - result.to_csv(count_result_file_path, index_label='index') - print('Done!') - return result - - def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result.csv', add_note=False, add_smiles=False, n_jobs=1, batch_size='auto'): - print('reading the input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplementedError( - 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') - - mol_number = len(smiles_iterator) - print('Done, totally detected {} molecules, start counting...'.format(mol_number)) - task = [delayed(self.count_a_mol)(i, add_note=add_note, add_smiles=add_smiles) for i in smiles_iterator] - count_result_dict_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('Done!') - print('writing to csv...') - result = pd.DataFrame(count_result_dict_list) - result.to_csv(count_result_file_path, index_label='index') - print('Done!') - return result - - def get_group_fingerprint(self, mol): - count_result = self.count_a_mol(mol=mol, clear_mode=False, add_note=False) - return [value for key, value in count_result.items()] - - def count_1st_order_groups(self, mol, add_note=False): - atoms_index = set([i for i in range(len(mol.GetAtoms()))]) # 所有原子序号的集合 - used_atoms_index = set() - for function_index in self.f_order_group_function_order: - function = self.f_order_group_function[function_index] - _, tuple_of_match_tuples = function(mol) - for matched_index in tuple_of_match_tuples: - if used_atoms_index.intersection(matched_index) == set(): - used_atoms_index = used_atoms_index.union(matched_index) - self.result[function.__name__] += 1 - if used_atoms_index == atoms_index: - # early stop 统计第一顺序基团时,当所有原子都被使用后提前停止 - break - - if used_atoms_index != atoms_index: - warning = 'WARING: For {}, The first order groups do not cover the whole molecule! The results may not be reliable!'.format( - Chem.MolToSmiles(mol)) - print(warning) - if add_note: - if self.result.get('note', 0): - self.result['note'] += '\t' - self.result['note'] += warning - else: - self.result['note'] = warning - return None - - def count_2nd_order_groups(self, mol): - s_order_group_in_mol = [] - for function_index in self.s_order_group_function_order: - function = self.s_order_group_function[function_index] - _, tuple_of_match_tuples = function(mol) - for matched_index in tuple_of_match_tuples: - matched_index = set(matched_index) - for used in s_order_group_in_mol: # for else 语句 - if matched_index.issubset(used): - break - else: - s_order_group_in_mol.append(matched_index) - self.result[function.__name__] += 1 - return None - - def count_3rd_order_groups(self, mol): # 跟count_2nd_order_groups几乎完全一样 - t_order_group_in_mol = [] - for function_index in self.t_order_group_function_order: - function = self.t_order_group_function[function_index] - _, tuple_of_match_tuples = function(mol) - for matched_index in tuple_of_match_tuples: - matched_index = set(matched_index) - for used in t_order_group_in_mol: # for else 语句 - if matched_index.issubset(used): - break - else: - t_order_group_in_mol.append(matched_index) - self.result[function.__name__] += 1 - return None - - -# if __name__ == '__main__': -# print('debug gp_counter.py ...') -# import time -# -# t1 = time.time() -# # m = Chem.MolFromSmiles('Cc1ncc[nH]1') -# c = Counter() -# # result = c.count_a_mol(m, clear_mode=True) -# # print(result) -# # -# # print(c.get_group_fingerprint(m)) -# -# c.count_mols_mpi(smiles_file_path=os.path.join('gp_3x_test_mol', 'SMILES.txt'), count_result_file_path='count_result.csv', add_note=True, -# n_jobs=4, batch_size='auto') -# -# -# t2 = time.time() -# print(t2 - t1) \ No newline at end of file diff --git a/build/lib/src/gp_3x_generator.py b/build/lib/src/gp_3x_generator.py deleted file mode 100644 index 7902de2..0000000 --- a/build/lib/src/gp_3x_generator.py +++ /dev/null @@ -1,222 +0,0 @@ -from rdkit import Chem -import os -from tqdm import tqdm -from joblib import Parallel, delayed -import time -import random - -from gp_3x_convertor import Convertor -from gp_3x_tool import Tool - - -class Generator: - def __init__(self): - pass - - def calculate_charge(self, smi): - if isinstance(smi, str): - smi = Chem.MolFromSmiles(smi) - net_charge = 0 - all_charge = 0 - for i in smi.GetAtoms(): - net_charge += i.GetFormalCharge() - all_charge += i.GetAtomicNum() - - all_charge -= net_charge - - return net_charge, all_charge - - def calculate_multiplicity(self, smi): - if isinstance(smi, str): - smi = Chem.MolFromSmiles(smi) - net_charge, all_charge = self.calculate_charge(smi) - alpha_minus_beta = all_charge % 2 - multiplicity = alpha_minus_beta + 1 - return multiplicity - - def smi_to_gjf(self, smi, nproc='12', mem='12GB', chk_path=None, gjf_path=None, - gaussian_keywords=None, charge_and_multiplicity=None, - add_other_tasks=False, other_tasks:list=None): - try: - # default path of chk and gjf - if chk_path is None: - chk_path = '{}.chk'.format(smi) - if gjf_path is None: - gjf_path = '{}.gjf'.format(smi) - assert ('(' not in gjf_path) and (')' not in gjf_path), \ - 'gaussian dose not allow ( or ) in the name of .gjf and .chk files' - assert ('(' not in chk_path) and (')' not in chk_path), \ - 'gaussian dose not allow ( or ) in the name of .gjf and .chk files' - # default task - if gaussian_keywords is None: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - # default charge and multiplicity - if charge_and_multiplicity is None: - charge_and_multiplicity = f'{self.calculate_charge(smi)[0]} {self.calculate_multiplicity(smi)}' - # default other tasks - if other_tasks is None: - other_tasks = [ - '#p m062x/def2tzvp geom=check', - '#p m062x/def2tzvp scrf=solvent=water geom=check', - ] - - # read smi - c = Convertor() - temp_xyz_path = f'temp_{str(time.time()) + str(random.randint(0,1000000000000000000))}.xyz' - c.smi_to_xyz(smi=smi, xyz_path=temp_xyz_path) - - # write gjf - # 判断是否存在同名gjf - if os.path.exists(gjf_path): - os.remove(gjf_path) - self.write_gjf_link0_and_keyword(gjf_path=gjf_path, chk_path=chk_path, nproc=nproc, mem=mem, - gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, note=smi) - self.write_gjf_coord(gjf_path=gjf_path, xyz_path=temp_xyz_path) - - if add_other_tasks: - for task_index, task in enumerate(other_tasks): - i_chk_path = chk_path.split('.')[0] + f'_{task_index + 1}' + '.chk' - self.write_gjf_link0_and_keyword(gjf_path=gjf_path, chk_path=i_chk_path, nproc=nproc, mem=mem, - gaussian_keywords=task, charge_and_multiplicity=charge_and_multiplicity, - note=smi, old_chk_path=chk_path, add_link1=True) - self.write_gjf_blank_line(gjf_path=gjf_path, blank_line_number=2) - - # 删除临时xyz文件 - os.remove(temp_xyz_path) - return True - except: - print(f'Error! There is something wrong when converting {smi} to gjf file, please check it.') - return False - - def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, - nproc='12', mem='12GB', chk_path=None, gjf_path=None, - gaussian_keywords=None, charge_and_multiplicity=None, - add_other_tasks=False, other_tasks: list = None, - index_start=0, - ): - smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) - mol_number = len(smiles_iterator) - zfill_number = len(str(mol_number)) + 5 - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - - # make gjf_root_path - if os.path.exists(gjf_root_path): - print('gjf_root_path "{}" has been detected!'.format(gjf_root_path)) - else: - print('gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path)) - os.makedirs(gjf_root_path) - # end - - succeed = [] - fail = [] - for (index, smi) in tqdm(enumerate(smiles_iterator)): - smi = smi.strip() - index += index_start - chk_path = '{}.chk'.format(str(index).zfill(zfill_number)) - gjf_path = os.path.join(gjf_root_path, '{}.gjf'.format(str(index).zfill(zfill_number))) - generate_success_flag = self.smi_to_gjf(smi=smi, nproc=nproc, mem=mem, - chk_path=chk_path, gjf_path=gjf_path, - gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks, - ) - if not generate_success_flag: - fail.append(smi) - else: - succeed.append(smi) - - with open('gjf_fail.txt', 'w') as f: - for i in fail: - f.write(i + '\n') - with open('gjf_succeed.txt', 'w') as f: - for i in succeed: - f.write(i + '\n') - if len(fail) == 0: - print('done! all .gjf files has been saved in {}'.format(gjf_root_path)) - else: - print('Warning! The following SMILES fail to generate .gjf, please check...sorry(OTZ)') - print(fail) - return None - - def batch_smi_to_gjf_mpi(self, smiles_file_path, gjf_root_path=None, - nproc='12', mem='12GB', chk_path=None, gjf_path=None, - gaussian_keywords=None, charge_and_multiplicity=None, - add_other_tasks=False, other_tasks: list = None, - index_start=0, - n_jobs=1, batch_size='auto' - ): - smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) - mol_number = len(smiles_iterator) - zfill_number = len(str(mol_number)) + 5 - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - - # make gjf_root_path - if os.path.exists(gjf_root_path): - print('gjf_root_path "{}" has been detected!'.format(gjf_root_path)) - else: - print('gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path)) - os.makedirs(gjf_root_path) - - # task - task = [delayed(self.smi_to_gjf)(smi=smi, nproc=nproc, mem=mem, - chk_path='{}.chk'.format(str(index).zfill(zfill_number)), - gjf_path=os.path.join(gjf_root_path, '{}.gjf'.format(str(index).zfill(zfill_number))), - gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks,) - for index, smi in enumerate(smiles_iterator)] - result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - return result - - @staticmethod - def write_gjf_link0_and_keyword(gjf_path, chk_path, nproc, mem, gaussian_keywords, charge_and_multiplicity, note, - old_chk_path=None, add_link1=False): - with open(gjf_path, 'a') as gjf: - if add_link1: - gjf.write('--link1--' + '\n') - gjf.write(f'%nproc={nproc}' + '\n') - gjf.write(f'%mem={mem}' + '\n') - if old_chk_path is not None: - gjf.write(f'%oldchk={old_chk_path}' + '\n') - gjf.write(f'%chk={chk_path}' + '\n') - gjf.write(f'{gaussian_keywords}' + '\n') - gjf.write('\n') - gjf.write(f'{note}' + '\n') - gjf.write('\n') - gjf.write(f'{charge_and_multiplicity}' + '\n') - gjf.close() - return None - - @staticmethod - def write_gjf_coord(gjf_path, xyz_path): - xyz = open(xyz_path) - with open(gjf_path, 'a') as gjf: - for i in xyz.readlines()[2:]: - gjf.write(i) - gjf.write('\n\n') - return None - - @staticmethod - def write_gjf_blank_line(gjf_path, blank_line_number=1): - with open(gjf_path, 'a') as gjf: - gjf.write('\n' * blank_line_number) - return None - - - - - -# if __name__ == '__main__': -# -# import time -# g = Generator() -# t1 = time.time() -# # g.smi_to_gjf(smi='C1CCCC1', add_other_tasks=True) -# # g.batch_smi_to_gjf(smiles_file_path='gp_3x_test_mol/3018_with_error_smiles.txt', gjf_root_path='./test_gjf') -# g.batch_smi_to_gjf_mpi(smiles_file_path='gp_3x_test_mol/3018_with_error_smiles.txt', gjf_root_path='./test_gjf', -# add_other_tasks=True, -# n_jobs=8, batch_size='auto') -# -# t2 = time.time() -# print(t2 - t1) \ No newline at end of file diff --git a/build/lib/src/gp_3x_loader.py b/build/lib/src/gp_3x_loader.py deleted file mode 100644 index 31aa8a4..0000000 --- a/build/lib/src/gp_3x_loader.py +++ /dev/null @@ -1,31 +0,0 @@ -import pandas as pd -import os - - -class Loader: - def __init__(self, load_path=os.path.join('gp_3x_internal_data', 'group_contribution_parameters.xlsx')): - self.load_path = load_path - - def load_parameters(self, parameter_type='simultaneous', split=False): - assert parameter_type in ['simultaneous', 'step_wise'], '请确保参数类型为simultaneous或step_wise!' - step_wise_first_order = pd.read_excel(self.load_path, sheet_name='{}_first_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_second_order = pd.read_excel(self.load_path, sheet_name='{}_second_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_third_order = pd.read_excel(self.load_path, sheet_name='{}_third_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_universal_constants = pd.read_excel(self.load_path, sheet_name='{}_constants'.format(parameter_type), index_col='index').T.to_dict() - - if split: - return step_wise_first_order, step_wise_second_order, step_wise_third_order, step_wise_universal_constants - else: - return {**step_wise_first_order, **step_wise_second_order, **step_wise_third_order, **step_wise_universal_constants} - - -# if __name__ == '__main__': -# -# # debug -# loader = Loader() -# -# d = loader.load_parameters() -# for i in d: -# print(i) -# print(d[i]) - diff --git a/build/lib/src/gp_3x_tool.py b/build/lib/src/gp_3x_tool.py deleted file mode 100644 index a41bce3..0000000 --- a/build/lib/src/gp_3x_tool.py +++ /dev/null @@ -1,56 +0,0 @@ -import pandas as pd - - -class Tool: - def __init__(self): - pass - - def __repr__(self): - return 'This is a object implemented some useful tools' - - @staticmethod - def load_smiles_iterator(smiles_file_path): - print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplemented('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - smiles_iterator = [i.strip() for i in smiles_iterator] - return smiles_iterator - - -def export_a_dict(result_dict, export_path='result.csv'): - df = pd.DataFrame([result_dict]) - df.to_csv(export_path, index_label='index') - return None - - -logo = \ -''' ---------------------------------------------------------------------------------- -Groupy -- A Useful Tool for Molecular Analysis -Developer: Ruichen Liu -Hint: Please feel easy to contact the developer if you have any problems in use. -E-mail1: liuruichen@tju.edu.cn -E-mail2: 1197748182@qq.com (may reply more quickly than E-mail1) ---------------------------------------------------------------------------------- -''' - - -# if __name__ == '__main__': -# print('debug gp_tool.py') -# -# t = Tool() -# -# t.smi_to_xyz('C1CCCC1') -# # t.batch_smi_to_xyz(smiles_file_path=r'gp_3x_test_mol\SMILES.txt', xyz_root_path='test_xyz') -# # t.convert_file_type(in_format='xyz', in_path='C1CCCC1.xyz', out_format='mol2') -# # t.batch_convert_file_type(in_format='xyz', in_root_path='test_xyz', out_format='mol2', out_root_path=None) -# # t.batch_convert_file_type(in_format='xyz', in_root_path='test_xyz', out_format='mol2', out_root_path='test_mol2') -# -# t.smi_to_gjf(smi='C1CCC1', add_other_std_tasks=True) -# t.batch_smi_to_gjf(smiles_file_path=r'gp_3x_test_mol\SMILES.txt', gjf_root_path='test_gjf', add_other_std_tasks=True) diff --git a/build/lib/src/gp_3x_viewer.py b/build/lib/src/gp_3x_viewer.py deleted file mode 100644 index 0d19b12..0000000 --- a/build/lib/src/gp_3x_viewer.py +++ /dev/null @@ -1,96 +0,0 @@ -import ase -from ase.visualize import view -from ase.io import read -import os -from pprint import pprint - -from gp_3x_convertor import Convertor - - -class Viewer: - def __init__(self): - pass - - def view_mol(self, mol, mol_type='smi'): - if isinstance(mol, str): - if mol_type in ['smi', 'smiles', 'SMILES']: - convertor = Convertor() - convertor.smi_to_xyz(smi=mol, xyz_path='temp.xyz') - mol = read(filename='temp.xyz', format='xyz') - os.remove('temp.xyz') - else: - try: - mol = read(filename=mol, format=mol_type) - except: - convertor = Convertor() - convertor.convert_file_type(in_format=mol_type, in_path=mol, out_format='xyz', out_path='temp.xyz') - mol = read(filename='temp.xyz', format='xyz') - os.remove('temp.xyz') - view(mol) - - def plot_supported_format(self): - ase_format = ase.io.formats.ioformats - openbabel_format = {'abinit': 'ABINIT Output Format', 'acesout': 'ACES output format', 'acr': 'ACR format', - 'adfband': 'ADF Band output format', 'adfdftb': 'ADF DFTB output format', 'adfout': 'ADF output format', - 'alc': 'Alchemy format', 'aoforce': 'Turbomole AOFORCE output format', - 'arc': 'Accelrys/MSI Biosym/Insight II CAR format', 'axsf': 'XCrySDen Structure Format', - 'bgf': 'MSI BGF format', 'box': 'Dock 3.5 Box format', 'bs': 'Ball and Stick format', - 'c09out': 'Crystal 09 output format', 'c3d1': 'Chem3D Cartesian 1 format', 'c3d2': 'Chem3D Cartesian 2 format', - 'caccrt': 'Cacao Cartesian format', 'can': 'Canonical SMILES format', - 'car': 'Accelrys/MSI Biosym/Insight II CAR format', 'castep': 'CASTEP format', 'ccc': 'CCC format', - 'cdjson': 'ChemDoodle JSON', 'cdx': 'ChemDraw binary format', 'cdxml': 'ChemDraw CDXML format', - 'cif': 'Crystallographic Information File', 'ck': 'ChemKin format', 'cml': 'Chemical Markup Language', - 'cmlr': 'CML Reaction format', 'cof': 'Culgi object file format', 'CONFIG': 'DL-POLY CONFIG', - 'CONTCAR': 'VASP format', 'CONTFF': 'MDFF format', 'crk2d': 'Chemical Resource Kit diagram(2D)', - 'crk3d': 'Chemical Resource Kit 3D format', 'ct': 'ChemDraw Connection Table format', - 'cub': 'Gaussian cube format', 'cube': 'Gaussian cube format', 'dallog': 'DALTON output format', - 'dalmol': 'DALTON input format', 'dat': 'Generic Output file format', 'dmol': 'DMol3 coordinates format', - 'dx': 'OpenDX cube format for APBS', 'ent': 'Protein Data Bank format', - 'exyz': 'Extended XYZ cartesian coordinates format', 'fa': 'FASTA format', 'fasta': 'FASTA format', - 'fch': 'Gaussian formatted checkpoint file format', 'fchk': 'Gaussian formatted checkpoint file format', - 'fck': 'Gaussian formatted checkpoint file format', 'feat': 'Feature format', 'fhiaims': 'FHIaims XYZ format', - 'fract': 'Free Form Fractional format', 'fs': 'Fastsearch format', 'fsa': 'FASTA format', - 'g03': 'Gaussian Output', 'g09': 'Gaussian Output', 'g16': 'Gaussian Output', - 'g92': 'Gaussian Output', 'g94': 'Gaussian Output', 'g98': 'Gaussian Output', 'gal': 'Gaussian Output', - 'gam': 'GAMESS Output', 'gamess': 'GAMESS Output', 'gamin': 'GAMESS Input', 'gamout': 'GAMESS Output', - 'got': 'GULP format', 'gpr': 'Ghemical format', 'gro': 'GRO format', 'gukin': 'GAMESS-UK Input', - 'gukout': 'GAMESS-UK Output', 'gzmat': 'Gaussian Z-Matrix Input', 'hin': 'HyperChem HIN format', - 'HISTORY': 'DL-POLY HISTORY', 'inchi': 'InChI format', 'inp': 'GAMESS Input', 'ins': 'ShelX format', - 'jin': 'Jaguar input format', 'jout': 'Jaguar output format', 'log': 'Generic Output file format', - 'lpmd': 'LPMD format', 'mcdl': 'MCDL format', 'mcif': 'Macromolecular Crystallographic Info', - 'MDFF': 'MDFF format', 'mdl': 'MDL MOL format', 'ml2': 'Sybyl Mol2 format', - 'mmcif': 'Macromolecular Crystallographic Info', 'mmd': 'MacroModel format', 'mmod': 'MacroModel format', - 'mol': 'MDL MOL format', 'mol2': 'Sybyl Mol2 format', 'mold': 'Molden format', 'molden': 'Molden format', - 'molf': 'Molden format', 'moo': 'MOPAC Output format', 'mop': 'MOPAC Cartesian format', - 'mopcrt': 'MOPAC Cartesian format', 'mopin': 'MOPAC Internal', 'mopout': 'MOPAC Output format', - 'mpc': 'MOPAC Cartesian format', 'mpo': 'Molpro output format', 'mpqc': 'MPQC output format', - 'mrv': 'Chemical Markup Language', 'msi': 'Accelrys/MSI Cerius II MSI format', 'nwo': 'NWChem output format', - 'orca': 'ORCA output format', 'out': 'Generic Output file format', 'outmol': 'DMol3 coordinates format', - 'output': 'Generic Output file format', 'pc': 'PubChem format', 'pcjson': 'PubChem JSON', - 'pcm': 'PCModel Format', 'pdb': 'Protein Data Bank format', 'pdbqt': 'AutoDock PDBQT format', - 'png': 'PNG 2D depiction', 'pos': 'POS cartesian coordinates format', 'POSCAR': 'VASP format', - 'POSFF': 'MDFF format', 'pqr': 'PQR format', 'pqs': 'Parallel Quantum Solutions format', - 'prep': 'Amber Prep format', 'pwscf': 'PWscf format', 'qcout': 'Q-Chem output format', 'res': 'ShelX format', - 'rsmi': 'Reaction SMILES format', 'rxn': 'MDL RXN format', 'sd': 'MDL MOL format', 'sdf': 'MDL MOL format', - 'siesta': 'SIESTA format', 'smi': 'SMILES format', 'smiles': 'SMILES format', - 'smy': 'SMILES format using Smiley parser', 'sy2': 'Sybyl Mol2 format', 't41': 'ADF TAPE41 format', - 'tdd': 'Thermo format', 'text': 'Read and write raw text', 'therm': 'Thermo format', - 'tmol': 'TurboMole Coordinate format', 'txt': 'Title format', 'txyz': 'Tinker XYZ format', - 'unixyz': 'UniChem XYZ format', 'VASP': 'VASP format', 'vmol': 'ViewMol format', - 'wln': 'Wiswesser Line Notation', 'xml': 'General XML format', 'xsf': 'XCrySDen Structure Format', - 'xyz': 'XYZ cartesian coordinates format', 'yob': 'YASARA.org YOB format'} - print('-' * 40) - pprint('ASE format:') - pprint(ase_format) - print('-' * 40) - pprint('OpenBabel format:') - pprint(openbabel_format) - return None - - - -# if __name__ == '__main__': -# -# viewer = Viewer() -# viewer.view_mol('gp_3x_test_mol/test_xyz/000000.mol2', mol_type='mol2') -# viewer.plot_supported_format() \ No newline at end of file diff --git a/build/lib/src/groupy.py b/build/lib/src/groupy.py deleted file mode 100644 index 8e976c0..0000000 --- a/build/lib/src/groupy.py +++ /dev/null @@ -1,559 +0,0 @@ -from rdkit import Chem - -from gp_3x_calculator import Calculator -from gp_3x_counter import Counter -from gp_3x_viewer import Viewer -from gp_3x_convertor import Convertor -from gp_3x_generator import Generator -from gp_3x_tool import Tool, export_a_dict, logo - - -def main_function_5(): - - while True: - flag_file = input( - '\n' - '--------------------------------------------------------------------------------- \n' - 'You are in main function 5 \n' - 'what to do? \n' - ' 0. return to main interface. \n' - ' 1. generate a .xyz file by input SMILES of a molecule. \n' - ' 2. generate a batch of .xyz files. -2. use mpi to accelerate.\n' - ' 3. convert a file to other format (e.g. xyz, mol, mol2, pdb...) \n' - ' 4. convert a batch of file to other format. -4. use mpi to accelerate.\n' - ' 5. generate .gjf(input file of gaussian) file by input SMILES of a molecule. \n' - ' 6. generate a batch of .gjf files. -6. use mpi to accelerate. \n' - '--------------------------------------------------------------------------------- \n' - ) - - - if flag_file == '0': - break - - elif flag_file == '1': - sub_function_1_of_main_function_5() - - elif flag_file == '2': - sub_function_2_of_main_function_5() - - elif flag_file == '-2': - sub_function_minus_2_of_main_function_5() - - elif flag_file == '3': - sub_function_3_of_main_function_5() - - elif flag_file == '4': - sub_function_4_of_main_function_5() - - elif flag_file == '-4': - sub_function_minus_4_of_main_function_5() - - elif flag_file == '5': - sub_function_5_of_main_function_5() - - elif flag_file == '6': - sub_function_6_of_main_function_5() - - elif flag_file == '-6': - sub_function_minus_6_of_main_function_5() - - return None - - -def sub_function_1_of_main_function_5(): - """ - 1. generate a .xyz file by input SMILES of a molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - xyz_file = input( - 'please input the path of output .xyz file. If press Enter directly, {}.xyz will be used\n'.format(smiles)) - if not xyz_file: - xyz_file = '{}.xyz'.format(smiles) - convertor = Convertor() - convertor.smi_to_xyz(smi=smiles, xyz_path=xyz_file) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_2_of_main_function_5(): - """ - 2. generate a batch of .xyz files. - """ - smiles_file_path = input('input a filepath of a file in which save SMILES of molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - xyz_root_path = input('input the root path of output xyz files, that is, ' - 'all the output xyz files will be make in this path. e.g. test_xyz \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n') - convertor = Convertor() - convertor.batch_smi_to_xyz(smiles_file_path=smiles_file_path, xyz_root_path=xyz_root_path) - print('\n\n\n') - return None - - -def sub_function_minus_2_of_main_function_5(): - """ - 2. generate a batch of .xyz files. use mpi - """ - smiles_file_path = input('input a filepath of a file in which save SMILES of molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - xyz_root_path = input('input the root path of output xyz files, that is, ' - 'all the output xyz files will be make in this path. e.g. test_xyz \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - convertor = Convertor() - convertor.batch_smi_to_xyz_mpi(smiles_file_path=smiles_file_path, xyz_root_path=xyz_root_path, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def sub_function_3_of_main_function_5(): - """ - 3. convert a file to other format (e.g. xyz, mol, mol2, pdb...) - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_path = input('please input the path of input file, e.g. C1CCC1.xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - out_path = input('please input the path of output file, e.g C1CCC1.mol2. \n') - convertor = Convertor() - convertor.convert_file_type(in_format=in_format, in_path=in_path, out_format=out_format, out_path=out_path) - print('Done!') - print('\n\n\n') - - -def sub_function_4_of_main_function_5(): - """ - 4. convert a batch of file to other format. - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_root_path = input('please input the root path of input files, that is, ' - 'all input files you want to convert should be in there.' - 'e.g. test_xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - default_out_root_path = in_root_path - out_root_path = input('please input the root path of output file, that is, ' - 'all the output files will be saved in there\n' - 'If press Enter directly, {} will be used \n'.format(default_out_root_path)) - if not out_root_path: - out_root_path = default_out_root_path - convertor = Convertor() - convertor.batch_convert_file_type(in_format=in_format, in_root_path=in_root_path, - out_format=out_format, out_root_path=out_root_path) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_minus_4_of_main_function_5(): - """ - -4. convert a batch of file to other format. use mpi. - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_root_path = input('please input the root path of input files, that is, ' - 'all input files you want to convert should be in there.' - 'e.g. test_xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - default_out_root_path = in_root_path - out_root_path = input('please input the root path of output file, that is, ' - 'all the output files will be saved in there\n' - 'If press Enter directly, {} will be used \n'.format(default_out_root_path)) - if not out_root_path: - out_root_path = default_out_root_path - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - convertor = Convertor() - convertor.batch_convert_file_type_mpi(in_format=in_format, in_root_path=in_root_path, - out_format=out_format, out_root_path=out_root_path, - n_jobs=n_jobs, batch_size=batch_size) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_5_of_main_function_5(): - """ - 5. generate .gjf(input file of gaussian) file by input SMILES of a molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - chk_path = input('input the path of chk file. e.g. Cc1ccccc1.chk \n' - 'Hint1: If press Enter directly, {}.chk will be used. \n' - 'Hint2: Attention please! the symbol such as (, ), /, \\ and # should not appear in a filepath! \n' - .format(smiles)) - if not chk_path: - chk_path = '{}.chk'.format(smiles) - - gjf_path = input('input the path of gjf file. e.g. Cc1ccccc1.gjf \n' - 'Hint1: If press Enter directly, {}.gjf will be used. \n' - 'Hint2: Attention please! the symbol such as (, ), /, \\ and # should not appear in a filepath! \n' - .format(smiles)) - if not gjf_path: - gjf_path = '{}.gjf'.format(smiles) - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - generator = Generator() - generator.smi_to_gjf(smi=smiles, nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - chk_path=chk_path, gjf_path=gjf_path, - add_other_tasks=add_other_tasks, other_tasks=other_tasks) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_6_of_main_function_5(): - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - - gjf_root_path = input('Input the root path of output gjf files, that is, ' - 'all the output gjf files will be make in this path. e.g. test_gjf \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: if press Enter directly, test_gjf will be used. \n') - if not gjf_root_path: - gjf_root_path = 'test_gjf' - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - generator = Generator() - generator.batch_smi_to_gjf(smiles_file_path=smiles_file_path, gjf_root_path=gjf_root_path, - nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks) - print('\n\n\n') - return None - - -def sub_function_minus_6_of_main_function_5(): - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - - gjf_root_path = input('Input the root path of output gjf files, that is, ' - 'all the output gjf files will be make in this path. e.g. test_gjf \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: if press Enter directly, test_gjf will be used. \n') - if not gjf_root_path: - gjf_root_path = 'test_gjf' - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - - generator = Generator() - generator.batch_smi_to_gjf_mpi(smiles_file_path=smiles_file_path, gjf_root_path=gjf_root_path, - nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main_function_view(): - viewer = Viewer() - show_flag = input('show a SMILES (enter 1) or file (enter 2). \n(enter help to show supported file formats)\n') - if show_flag in ['1', 'SMILES', 'smiles']: - smiles = input('input the SMILES of a molecule. \n') - viewer.view_mol(mol=smiles, mol_type='smi') - - elif show_flag in ['2', 'file', 'FILE']: - file_path = input('input the file path you want to show. e.g. ./temporary.xyz \n') - file_type = input('input file format. e.g. xyz \n') - viewer.view_mol(mol=file_path, mol_type=file_type) - - elif show_flag in ['h', 'help', 'H', 'Help', 'HELP']: - viewer.plot_supported_format() - - else: - print('Unrecognized command!') - return None - - -def main_function_1(): - """ - 1. calculate properties of a molecule by input SMILES of this molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - calculator = Calculator() - result = calculator.calculate_a_mol(smiles, debug=False) - print(result) - export_flag = input('Do you want to export results to a csv file? (y/n) \n') - if export_flag in ['y', 'Y', '1']: - export_a_dict(result_dict=result, export_path='{}_calculate.csv'.format(smiles)) - print('the results have been export to {}_calculate.csv! \n\n'.format(smiles)) - else: - print('\n\n\n') - return None - - -def main_function_2(): - """ - 2. count group number of a molecule by input SMILES of this molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - mol = Chem.MolFromSmiles(smiles) - counter = Counter() - clear_mode_flag = input('clear mode? (y/n) \n') - if clear_mode_flag in ['y', 'Y', '1']: - result = counter.count_a_mol(mol, clear_mode=True) - else: - result = counter.count_a_mol(mol, clear_mode=False) - print(result) - export_flag = input('Do you want to export results to a file? (y/n) \n') - if export_flag in ['y', 'Y', '1']: - export_a_dict(result_dict=result, export_path='{}_count.csv'.format(smiles)) - print('the results have been export to {}_count.csv! \n\n'.format(smiles)) - else: - print('\n\n\n') - return None - - -def main_function_3(): - """ - 3. calculate properties of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - calculator = Calculator() - calculator.calculate_mols(smiles_file_path=smiles_file_path, - properties_file_path='batch_calculate_results.csv') - print('\n\n\n') - return None - - -def main_function_minus_3(): - """ - -3. calculate properties of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - use mpi - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - calculator = Calculator() - calculator.calculate_mols_mpi(smiles_file_path=smiles_file_path, - properties_file_path='batch_calculate_results_mpi.csv', - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main_function_4(): - """ - 4. count group number of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - """ - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - counter = Counter() - counter.count_mols(smiles_file_path=smiles_file_path, - count_result_file_path='batch_count_result.csv', add_note=True, add_smiles=True) - print('\n\n\n') - return None - - -def main_function_minus_4(): - """ - -4. count group number of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - use mpi - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - counter = Counter() - counter.count_mols_mpi(smiles_file_path=smiles_file_path, - count_result_file_path='batch_count_result_mpi.csv', add_note=True, add_smiles=True, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main(): - print(logo) - - while True: - - flag_main = input( - '\n' - '--------------------------------------------------------------------------------- \n' - 'You are in main interface \n' - 'what to do? \n' - ' q. exit \n' - ' 0. show molecular structure by SMILES or file. \n' - ' 1. calculate properties of a molecule. \n' - ' 2. count group number of a molecule. \n' - ' 3. calculate properties of a batch of molecules. -3. use mpi to accelerate.\n' - ' 4. count group number of a batch of molecules. -4. use mpi to accelerate.\n' - ' 5. generate files or covert file format for MD, DFT, Visualization... \n' - '--------------------------------------------------------------------------------- \n' - ) - - if flag_main == 'q': - print('exit Groupy, have a nice day!') - break - - elif flag_main == '0': - main_function_view() - - elif flag_main == '1': - main_function_1() - - elif flag_main == '2': - main_function_2() - - elif flag_main == '3': - main_function_3() - - elif flag_main == '-3': - main_function_minus_3() - - elif flag_main == '4': - main_function_4() - - elif flag_main == '-4': - main_function_minus_4() - - elif flag_main == '5': - main_function_5() - - else: - print('Please input the right option. ' - 'For more information, you are supposed to read the manual.') - - -if __name__ == '__main__': - - main() diff --git a/build/scripts-3.11/groupy.py b/build/scripts-3.11/groupy.py deleted file mode 100644 index 8e976c0..0000000 --- a/build/scripts-3.11/groupy.py +++ /dev/null @@ -1,559 +0,0 @@ -from rdkit import Chem - -from gp_3x_calculator import Calculator -from gp_3x_counter import Counter -from gp_3x_viewer import Viewer -from gp_3x_convertor import Convertor -from gp_3x_generator import Generator -from gp_3x_tool import Tool, export_a_dict, logo - - -def main_function_5(): - - while True: - flag_file = input( - '\n' - '--------------------------------------------------------------------------------- \n' - 'You are in main function 5 \n' - 'what to do? \n' - ' 0. return to main interface. \n' - ' 1. generate a .xyz file by input SMILES of a molecule. \n' - ' 2. generate a batch of .xyz files. -2. use mpi to accelerate.\n' - ' 3. convert a file to other format (e.g. xyz, mol, mol2, pdb...) \n' - ' 4. convert a batch of file to other format. -4. use mpi to accelerate.\n' - ' 5. generate .gjf(input file of gaussian) file by input SMILES of a molecule. \n' - ' 6. generate a batch of .gjf files. -6. use mpi to accelerate. \n' - '--------------------------------------------------------------------------------- \n' - ) - - - if flag_file == '0': - break - - elif flag_file == '1': - sub_function_1_of_main_function_5() - - elif flag_file == '2': - sub_function_2_of_main_function_5() - - elif flag_file == '-2': - sub_function_minus_2_of_main_function_5() - - elif flag_file == '3': - sub_function_3_of_main_function_5() - - elif flag_file == '4': - sub_function_4_of_main_function_5() - - elif flag_file == '-4': - sub_function_minus_4_of_main_function_5() - - elif flag_file == '5': - sub_function_5_of_main_function_5() - - elif flag_file == '6': - sub_function_6_of_main_function_5() - - elif flag_file == '-6': - sub_function_minus_6_of_main_function_5() - - return None - - -def sub_function_1_of_main_function_5(): - """ - 1. generate a .xyz file by input SMILES of a molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - xyz_file = input( - 'please input the path of output .xyz file. If press Enter directly, {}.xyz will be used\n'.format(smiles)) - if not xyz_file: - xyz_file = '{}.xyz'.format(smiles) - convertor = Convertor() - convertor.smi_to_xyz(smi=smiles, xyz_path=xyz_file) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_2_of_main_function_5(): - """ - 2. generate a batch of .xyz files. - """ - smiles_file_path = input('input a filepath of a file in which save SMILES of molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - xyz_root_path = input('input the root path of output xyz files, that is, ' - 'all the output xyz files will be make in this path. e.g. test_xyz \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n') - convertor = Convertor() - convertor.batch_smi_to_xyz(smiles_file_path=smiles_file_path, xyz_root_path=xyz_root_path) - print('\n\n\n') - return None - - -def sub_function_minus_2_of_main_function_5(): - """ - 2. generate a batch of .xyz files. use mpi - """ - smiles_file_path = input('input a filepath of a file in which save SMILES of molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - xyz_root_path = input('input the root path of output xyz files, that is, ' - 'all the output xyz files will be make in this path. e.g. test_xyz \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - convertor = Convertor() - convertor.batch_smi_to_xyz_mpi(smiles_file_path=smiles_file_path, xyz_root_path=xyz_root_path, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def sub_function_3_of_main_function_5(): - """ - 3. convert a file to other format (e.g. xyz, mol, mol2, pdb...) - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_path = input('please input the path of input file, e.g. C1CCC1.xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - out_path = input('please input the path of output file, e.g C1CCC1.mol2. \n') - convertor = Convertor() - convertor.convert_file_type(in_format=in_format, in_path=in_path, out_format=out_format, out_path=out_path) - print('Done!') - print('\n\n\n') - - -def sub_function_4_of_main_function_5(): - """ - 4. convert a batch of file to other format. - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_root_path = input('please input the root path of input files, that is, ' - 'all input files you want to convert should be in there.' - 'e.g. test_xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - default_out_root_path = in_root_path - out_root_path = input('please input the root path of output file, that is, ' - 'all the output files will be saved in there\n' - 'If press Enter directly, {} will be used \n'.format(default_out_root_path)) - if not out_root_path: - out_root_path = default_out_root_path - convertor = Convertor() - convertor.batch_convert_file_type(in_format=in_format, in_root_path=in_root_path, - out_format=out_format, out_root_path=out_root_path) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_minus_4_of_main_function_5(): - """ - -4. convert a batch of file to other format. use mpi. - """ - in_format = input('please input the format of your input file (e.g. xyz, pdb...) \n') - in_root_path = input('please input the root path of input files, that is, ' - 'all input files you want to convert should be in there.' - 'e.g. test_xyz \n') - out_format = input('please input the format of output file you want (e.g. xyz, mol2...) \n') - default_out_root_path = in_root_path - out_root_path = input('please input the root path of output file, that is, ' - 'all the output files will be saved in there\n' - 'If press Enter directly, {} will be used \n'.format(default_out_root_path)) - if not out_root_path: - out_root_path = default_out_root_path - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - convertor = Convertor() - convertor.batch_convert_file_type_mpi(in_format=in_format, in_root_path=in_root_path, - out_format=out_format, out_root_path=out_root_path, - n_jobs=n_jobs, batch_size=batch_size) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_5_of_main_function_5(): - """ - 5. generate .gjf(input file of gaussian) file by input SMILES of a molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - chk_path = input('input the path of chk file. e.g. Cc1ccccc1.chk \n' - 'Hint1: If press Enter directly, {}.chk will be used. \n' - 'Hint2: Attention please! the symbol such as (, ), /, \\ and # should not appear in a filepath! \n' - .format(smiles)) - if not chk_path: - chk_path = '{}.chk'.format(smiles) - - gjf_path = input('input the path of gjf file. e.g. Cc1ccccc1.gjf \n' - 'Hint1: If press Enter directly, {}.gjf will be used. \n' - 'Hint2: Attention please! the symbol such as (, ), /, \\ and # should not appear in a filepath! \n' - .format(smiles)) - if not gjf_path: - gjf_path = '{}.gjf'.format(smiles) - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - generator = Generator() - generator.smi_to_gjf(smi=smiles, nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - chk_path=chk_path, gjf_path=gjf_path, - add_other_tasks=add_other_tasks, other_tasks=other_tasks) - print('Done!') - print('\n\n\n') - return None - - -def sub_function_6_of_main_function_5(): - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - - gjf_root_path = input('Input the root path of output gjf files, that is, ' - 'all the output gjf files will be make in this path. e.g. test_gjf \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: if press Enter directly, test_gjf will be used. \n') - if not gjf_root_path: - gjf_root_path = 'test_gjf' - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - generator = Generator() - generator.batch_smi_to_gjf(smiles_file_path=smiles_file_path, gjf_root_path=gjf_root_path, - nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks) - print('\n\n\n') - return None - - -def sub_function_minus_6_of_main_function_5(): - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - - gjf_root_path = input('Input the root path of output gjf files, that is, ' - 'all the output gjf files will be make in this path. e.g. test_gjf \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: if press Enter directly, test_gjf will be used. \n') - if not gjf_root_path: - gjf_root_path = 'test_gjf' - - nproc = input('input the CPU cores you want to use. e.g. 12 \n') - if not nproc: - nproc = '12' - - mem = input('input the memory you want to use. e.g. 12GB \n') - if not mem: - mem = '12GB' - - gaussian_keywords = input('input the keywords of Gaussian to define task you want to run.' - 'e.g. #p opt freq b3lyp/6-31g* \n' - 'Hint1: if press Enter directly, "#p opt freq b3lyp/6-31g*" will be used. \n') - if not gaussian_keywords: - gaussian_keywords = '#p opt freq b3lyp/6-31g*' - - charge_and_multiplicity = input('Input charge and multiplicity. e.g. 0 1 \n' - 'Hint: If press Enter directly, Groupy will automatically calculate them') - if not charge_and_multiplicity: - charge_and_multiplicity = None - - add_other_tasks = input('Weather to add some other tasks in this .gjf (y/n). \n') - if add_other_tasks in ['n', 'no', 'N']: - add_other_tasks = False - other_tasks = None - else: - other_tasks = input('Input keywords you want to add. ' - 'If there are more than one other tasks, Please separate them with commas (,) \n' - 'Hint: if press Enter directly, "#p m062x/def2tzvp geom=check,#p m062x/def2tzvp scrf=solvent=water geom=check" will be used \n') - if not other_tasks: - other_tasks = None - else: - other_tasks = other_tasks.split(',') - - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - - generator = Generator() - generator.batch_smi_to_gjf_mpi(smiles_file_path=smiles_file_path, gjf_root_path=gjf_root_path, - nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, - charge_and_multiplicity=charge_and_multiplicity, - add_other_tasks=add_other_tasks, other_tasks=other_tasks, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main_function_view(): - viewer = Viewer() - show_flag = input('show a SMILES (enter 1) or file (enter 2). \n(enter help to show supported file formats)\n') - if show_flag in ['1', 'SMILES', 'smiles']: - smiles = input('input the SMILES of a molecule. \n') - viewer.view_mol(mol=smiles, mol_type='smi') - - elif show_flag in ['2', 'file', 'FILE']: - file_path = input('input the file path you want to show. e.g. ./temporary.xyz \n') - file_type = input('input file format. e.g. xyz \n') - viewer.view_mol(mol=file_path, mol_type=file_type) - - elif show_flag in ['h', 'help', 'H', 'Help', 'HELP']: - viewer.plot_supported_format() - - else: - print('Unrecognized command!') - return None - - -def main_function_1(): - """ - 1. calculate properties of a molecule by input SMILES of this molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - calculator = Calculator() - result = calculator.calculate_a_mol(smiles, debug=False) - print(result) - export_flag = input('Do you want to export results to a csv file? (y/n) \n') - if export_flag in ['y', 'Y', '1']: - export_a_dict(result_dict=result, export_path='{}_calculate.csv'.format(smiles)) - print('the results have been export to {}_calculate.csv! \n\n'.format(smiles)) - else: - print('\n\n\n') - return None - - -def main_function_2(): - """ - 2. count group number of a molecule by input SMILES of this molecule. - """ - smiles = input('input the SMILES of a molecule. \n') - smiles = smiles.strip() - mol = Chem.MolFromSmiles(smiles) - counter = Counter() - clear_mode_flag = input('clear mode? (y/n) \n') - if clear_mode_flag in ['y', 'Y', '1']: - result = counter.count_a_mol(mol, clear_mode=True) - else: - result = counter.count_a_mol(mol, clear_mode=False) - print(result) - export_flag = input('Do you want to export results to a file? (y/n) \n') - if export_flag in ['y', 'Y', '1']: - export_a_dict(result_dict=result, export_path='{}_count.csv'.format(smiles)) - print('the results have been export to {}_count.csv! \n\n'.format(smiles)) - else: - print('\n\n\n') - return None - - -def main_function_3(): - """ - 3. calculate properties of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - calculator = Calculator() - calculator.calculate_mols(smiles_file_path=smiles_file_path, - properties_file_path='batch_calculate_results.csv') - print('\n\n\n') - return None - - -def main_function_minus_3(): - """ - -3. calculate properties of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - use mpi - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - calculator = Calculator() - calculator.calculate_mols_mpi(smiles_file_path=smiles_file_path, - properties_file_path='batch_calculate_results_mpi.csv', - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main_function_4(): - """ - 4. count group number of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - """ - smiles_file_path = input('input the filepath of a file in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - counter = Counter() - counter.count_mols(smiles_file_path=smiles_file_path, - count_result_file_path='batch_count_result.csv', add_note=True, add_smiles=True) - print('\n\n\n') - return None - - -def main_function_minus_4(): - """ - -4. count group number of a batch of molecules by input filepath of a file in which save molecules (.txt, .csv, .xlsx). - use mpi - """ - smiles_file_path = input('input the filepath of a file (.txt, .csv, .xlsx) in which save molecules. ' - 'e.g. ./gp_3x_test_mol/SMILES.txt \n' - 'Hint1: Pay attention to the difference of path format in Linux and Windows! \n' - 'Hint2: The file must not have blank line! \n') - n_jobs = int(input('input number of cores to use. e.g. 4 \n')) - batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') - try: - batch_size = int(batch_size) - except: - pass - counter = Counter() - counter.count_mols_mpi(smiles_file_path=smiles_file_path, - count_result_file_path='batch_count_result_mpi.csv', add_note=True, add_smiles=True, - n_jobs=n_jobs, batch_size=batch_size) - print('\n\n\n') - return None - - -def main(): - print(logo) - - while True: - - flag_main = input( - '\n' - '--------------------------------------------------------------------------------- \n' - 'You are in main interface \n' - 'what to do? \n' - ' q. exit \n' - ' 0. show molecular structure by SMILES or file. \n' - ' 1. calculate properties of a molecule. \n' - ' 2. count group number of a molecule. \n' - ' 3. calculate properties of a batch of molecules. -3. use mpi to accelerate.\n' - ' 4. count group number of a batch of molecules. -4. use mpi to accelerate.\n' - ' 5. generate files or covert file format for MD, DFT, Visualization... \n' - '--------------------------------------------------------------------------------- \n' - ) - - if flag_main == 'q': - print('exit Groupy, have a nice day!') - break - - elif flag_main == '0': - main_function_view() - - elif flag_main == '1': - main_function_1() - - elif flag_main == '2': - main_function_2() - - elif flag_main == '3': - main_function_3() - - elif flag_main == '-3': - main_function_minus_3() - - elif flag_main == '4': - main_function_4() - - elif flag_main == '-4': - main_function_minus_4() - - elif flag_main == '5': - main_function_5() - - else: - print('Please input the right option. ' - 'For more information, you are supposed to read the manual.') - - -if __name__ == '__main__': - - main() diff --git a/dist/Groupy-3.0.0-py3-none-any.whl b/dist/Groupy-3.0.0-py3-none-any.whl deleted file mode 100644 index 783d558382034b5bdc768aca9f4b0456fc9054e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 281830 zcmV(V>{aA9&` zZDn+2a&s?o!wrzJ+mu=g&ZQFjzR+nwtw(Y7eo44QZiDZac3NP0WFSC%id$)L19I+n-*)gOtoYG0(-S}#VBobd} z!~=D1IqaxyhYyL987ys6=`tYb3p{$MvgpvEuSSiOU^w#7_ff|N2z%sX;VANh`b71c z*`n=s^)OF|w?ds$gn-|I7NnF~iQorUs2bt^u>5FM4yYvebAG7SkP`tSi{%U76C z*PWZ%jtj#FSKwTHv~r%Aio>;|!nhnp3Bk0?!WMB@EO{(tRdSux72emaQS$BbZzl0A zJyhQK!VN&|M5w(sTkt_U5iUEzUaJj^*mW*Pcmt?$Cw`BMa5@hTS9_tTX(b&0Y9w!ZM4L#aetS`;d3pTfg*TD zwM3i6^4N&kZ>{68G)Iht5IG0^{K{J;49zww`_Oq7uu zVEhq!E&dTU`nY=~|C}RhKi}`i=evEHyamSpG z5mU@ziuxtsLGUYMqlR+9G6zUNb_#b+#oTenktV*tKVnx z*p2M$aerQURqg*&9QT3;rAABAzTkAk1s-=ApGz-y_5*>zi59s1>k4f4DP>tsI_R+{ z?fFW~3JE-d5O#g>6+rP<A({x_%1A^RHjnA{I@W5%%}KNH()h_oG9vlBgf|y6xx{7vJg@ z#43OaeaRW-d=K;_qMt`81Ty?ea@Y#HGCrk-9LqCJ)fg8mSjH{- zsj1REsn9D7F4KMYAoVi2$qSzTY^IG_Ror6C)=S zYm#DT77M0OxnA@nE}wmt36^o1Lh8F-r$un96lo15rsnFvB=bvSp~{ElXD0Ep#!Zzc zas-xU2SdU5;V<%EkF#ZA2YbyrAH>YrBZCAXpuF|D?_p6cvC%y1>vhUe2t7sfDmH?YQy0cGsB)@c$+D!zxV5F3oSUNl>VR~$ zeizZ00R|kJ*zAc^m;Cqm|(mmqn#nBmsk~Y z6;!kTlDoHG{nj~25GrwFb?0vqh&_ADo8d!7wg_Rb~0`2`k z5!=>0EV7jvWRT7>>()E(^|9T0Q~|4`POOQ>If_9eJ+gjQN#70rzXK(LVXGb4Ke(ns zfq-Cxz<~Y(<$sIU{~MbBL&!k?2^r}BVIKWI9-TlL%K=6}$aBbcpSj+ID>4OLLIZ4w z^~M88KmIpBiy6DP%5J9*@=s`3GVm#H}*|La%{u1jIWM z`@QplFcsxAi$G8HrY+U;DGn4vq>(Zkbn~X~E+Yz1>Y_-9fRVwc1Gnz_R=!bYcTk() zaz(*`GzfVIV1#8-;)z|woEI|YM0qr@JTMuzFaG^#gFUKiUP}pN6$BP2Az@&{6GuD% zA3qsf9tx-CSWMRNqGm^|WTl_ZM>;_nabQ83UMen>cA-T=y}{3#JXzqY z6u$1vI%d}=QPR@kIS8yoszWcX6+u3e?ME*m3Tyql4fwCl1C&r2F>Xwi!51i)UP({2I}R+gs3f04P0*z5$mKB z6`i7niPf``ra@@3*1Sps@lvUvwYR@s2mWApm<&BzwC` zhnc>&HuQ&TUGDcL#Cy0YBX)w%_8oZ|(iU5h^po^tuE;V9gZ$=xu2t9jmE zzJP=pO%<9M?6+zIVfvWj`yc63Xt~@wFw*H1nc>U0dZ3KdY(QS7^Wyk2TnHu?JSG$P z;+lEn4s`t`;Q2Q|6Lmor&(ZSmO7z#kW`8Q(R72+R(4;-V5R>n8_&0+`dkyI?gU!17 zORS244zar{L33aoL+}8RWkXE@4ei*d=>;X;f6ouiyDajf>9C&t&SKe7`o4}BZCTa> z`OiN5uc6ZCB9^u#;h_Tajqh)}-j9vmrw8esYaT>>b4d3(|1Nc`_rKkr7?oE_*+{g< z3HF}8Fo~GJDMO|)V^ib1nVzrDRK2gJmyMpUrxm{MXTI+pg0FW5z9YWRy_N5`4L;wY zp3kygzmHG7mzK9bd>%biS{TCwgB2->FlPZ-08e zUQY=3b^^nDK9u*9=ONl&LOp_b@Rdcr9&o3ezIVK!vFfvcSQ$izk5MGIF+gHvUq!&gQ>_sC70e3pV&B*97!;XVT&TeDbI=O_2@ zVTgC#vt3%A-&*Cxc>%lM-CuO{?3wy%ibl$0$d!i8@xSj^2QpBvNjF=SSL@^4B$~sY zwi(?slev@EK#`uKrOI(ftV%Y4b63^I7^Koj3UCDsuvN$JaoP*(@52!{be+S7=JJih zifdSInhZ(b_g??XDaZSby9F`NYwHxZ5X#Xp;&qL!2OG`JbZ96BqKQ!v&s;MX&2=EB z+T>lOqv-80UQ^egk_hWwcPov`*X@(H%eLR7|~}N@WeY9(zN3&3uyU*ynJ)!YTvRr>=wA zonUHJ6J#fvdTV*5wB_V*L6nA4i!0_~OUvd$d8Nfs;&olaDR(Yt*44;%UwsG4s_| z#x zuD5EkD-+I-D>>wq-OTkSA@m?J7j#!Iy|uH{G6AbS5uS?$)`nULu64`4-$l22l^y7MhBZ~1uV7N= z`j8>TJ-?9K>Jb+WhRj6}mmdnZD*GX%tNC!S^4cxT!&a2aLd+UUXw82-cuV%*`z|k= zyjj=&VISaB8;inw8DV;y@_OuYeiR>IENmHPUUv(1c^b^FGD9PWXTUalRkzvZB7X~+ zovJ*C)9|g~&i2IQ_y}T0BJz?W*cg5aFeSf7c{w3W=-%glD}2KiF6}PYJB>Av((s14 z-eD9l$~QC-BhbJVM%O;TNnJi&s7fI-!$i=az%_*7gHUaoHRwjKiM zX3xj&d2V9if=AOJ^6@Un~XI&<06{-Ad{Lm`K%8 zr;k}}W5Q1JDDNP*5jxXWpK(Qtee$=w^pbk0Vju^ahryyl!43$irjBE&qV*+cQw=?q z1i7ofv_K5NL|^F(RB={1h*h`TfQCo9<*fEQgoZ2?xjjt zQhqG(wZHyDKz{x9Pe2WR*3~t)@MawWnE*b=!gr;z`GAlI*ql zpyTRFTlA_fFTcjRjVk(B5adCXrbcn>U0a1YOOrplxwdM#gf)`=>_VTrU>L()Qp|KM z&8IOhjg-iy@kgOhnr&9J-7d0>HSjZz#t@JnNh$QJ$EcEc+}Zm(wACwWW7`%#VW^?@ zlsnTTtrK~kXs{B4z@ah)9kFS0Z^k#a-Zp!4t=*WR6s z=u_{{p7p)Hodb0^kqwKWCa6%>5doO>%c>RMdYP)>o{6li?dCtbi0R`V8t^T*qY5&p)kHFOXOzbW~1syWM%+!8Zc9@8lp8j<+aW>XwwGfZ!z!Q}?wcY$PBUiG`` zz3c~i0oOywpj9Q09a5&0n49`j<)tR)QQakq8@F&rVVs}o58OzP>qRh$|Hw`iO$P4CQOg`z1f%0~n6J=QIOMj5)FEuDI2;&IEM-qH|VQaq_C`#rR8sij4-LX-Xv-jc=F%6IE_W z`CHb@N(UT+LPk@LpO28a`&6B5`fWt1baTxek1MGp_A_UFE!4u!9${Dc#O9T%;V^;5 zFKM;<0~a$g)=pR~kNTw=()&M#6eak|usYMb>W<3;H2Ds;p@_NzoOhPU%HepqdJiB4 zfXkSIAe`5j``)1!g{sftlUhxh0X_m>@g~(E9+FdEZ=1SpPff4ytuRR_-vFoyK4E>n zJrhH5m6`Vb4eK-ZJ=D|DocZjP0)Y~Lb{0q0xoZrpex4?_UKP74;ZddIa?9LU zK!za)aYBB03V{+GH#Vf6M z*ekjR!&aM~FSBuT zVMWH^PQ^kblGARrMSFX9uMjd`h-q9%24NRoao5#+BV>F5&CAxN7(i)M$Sk8+#k4_Q z6=qD3@Cwl^`&UGq$n5WBp$pbogkTs-!W*Jm6CNO*J%-TSf z7~kc294SDtFviIU9)qDVf$mG0Y6xkwsvhyPyeO!2E1{_afx9Sn{s4h-mf;dd$f77TnBKFv9%APN_F5{LU~bMf zjcKu3c^D+KhLkBJ>`vcuDo{8+!g8(t-<$#I3RzX+qZcw1&hYbsMF_!lY9fi@x^ zPCzL${@&>4$AUXdejTp2 z-bO@iAPHw3D{z6r2YU75J#tr^L;yKrtH`;>G*C(_Xgk$=A*Rz}%VnG*ei~if5*On; z7IGg(<07YnVzicdy-Ugi;^S#GpM)>Q#~?mMazZfjm5&+4(K^H;^+aPE|4Sh*z;8}t zO@5u9L8k{9cLGM$%xU($7-o0uiO*2I zkbWl{iqy{^R-)|TsQ%s=^{ec`P#O{0H5x^`lR*JH^Y8+HUwFLixx=B0w73yHAp;fG zYk$XEXG!VT{FxKO2Wo88X(>{aXNUHY%VxR0;KZa z$va;Hg6CrkYzn-)tdmHva%T&PhkagY3;2Y1+DO=y>P3TTTbgYz!SnzX3+4dp!;_6z z4@+)dEv&xzVCViy1vrLh^btD-kk-@$8y2~PpTs!Iq#eH0c=Eap!6PJ9?-YVUJW<1< zg%2;TrW~}KxM>SkuOh`lslcD3fGiNntu4C`8?!LI)=v+|*oO3igK#AlO=?eOEYS`wR?Xnj&?2aFlHh{9UzNbckDD>f-_;jjF`8S%jEU#p>&$yLty z4hYkn^Y5$e0#Qrl^E>E4IR_`ODJV>3PV*^) zj}XEGI`_WNZn!NZV(EIwa1CUHPT*aPP`crAjHQy%A9A=rES)6Y>HU(x^E2Z)_Fs_W z0;_vx*~&?G-i6H}=;OIYdwh@cMJNMlD@rW~eag4HIKZhE=-ruru9zolO>l{{v@x)s z{k1E_HRw3-^3br-R4*BBS8pzr0qJ-Ggbbu$gn&N74g;RVYarqw#Q5baJrX zNW}A!Ho2MNB>toqA++8W&yMI?oQda2l%>;3>TChS5tO1@;->orF(OcVnN_%+GAnK| zwaP8;Nz^wX?>Tb&ehs@t^fxXA;S~Ta2whQvKA0dztX^D6l)$^;K+iOp#az*DGZ%2R zqew-t)D!hXXWCof^Hl9Gw=Acm_P$36YvgL!J=spi0Cj9dhdZc)MLf}a?N!`8bU2%4 z1S-j?t^BjN&r{YG1oRG|Or{`^07BrrM84N>o{?wPkl@dv?V~-)8v^-?Z~>$CEyU21}OgBEY=GbqIP}B?frQ%s>7xeJ#n##-gx*MK+2YsqhtRjnx z`L&--8H4V}d%>UJ=T&u-kiLc-+=TXPf8;vMLZPb@WI`gF){)zKbw{~)9qo7i`9b?g z%K{oA&cXSI$-+&_&vx8+@sXNH4EsVTtn$V27*BukGGH`^;T5OOqO?}3n=XJmY07fD@*}x_cKcegTnE8tv#6E4K zlQ6qT3rmN?jwGg8h*eq7o?Fl>ab*ThcykJ_l8NTaMa491!*xGl$n8o1e9k6Kk}>~M zCZg429^AnofkWHP85<4!nED_q-!(5|>FN1Tr7_Ygnom2aSDia3v~_$wb~$b>ba;!7 z@60n23M;GdiKZ?F+wZU&y87;k(Zps_oUFH1GLD3AN#X>GSYs@O$KogCTiqJd`N$hQ zIAT}VJ%wkq9+XJ5z021u%Q=a3c%pz9X{m-Et0}=@hIYOwczraju$1P+JUb}Zk-_ST z*4)rY;rc-+aJ=>WsD=f>ASTH-W&$>u*uOHV>y-n`Ln8Wa;G=@CW21~L?9+ECaaGQp zJL~vc;oMKMsjEgIb1-L`Fkk&Yzy#HAOvRv$$%1+KD_6$xumqmLnae$I#2GOC`HL`K zKLHL~i%*h3Z!n{Z`GyU}K~zfgaxZF$G5+e&Cga(RqDCT>EmQxpp7dFL370A!_0hW~ zAbyI*-$S`e^II1+^mN2w zkl%GKwu4VhK4iGc$r1L&xg%#VK?O*9**{Qz|Av4~E^#jPce#b6tu_oCDprv{Mg*J{ zII@UM3ZZ=pa|&W`Kof;rye=moS&uQ;Z-}12z0T%trvHF9G-)AS9xisq%aUYJ3!9^C z3LF=b-TH;ify0D48;e(Y%R#maBV-`W{wIhU7Jm_`(~(tELb^LJAb_ahZxjWK zcZx-Ydt?lncvcSuC9A;DyaqFaAvJpWN2$hSu??WzUQ(J~G$AN3C~`hPf%{8@nnuJy zoVnuyzmDV`1a~G}zJ%gDkqg|uP@*oHe@qD#Y2J`AdaRc`ym=47ptjC*&^YpkL5Y68 zEqGUJuNx^Yl6hLQ>zRTXDy-UHXY9BKu6l~x&f$g?_gn0mMGoBd);mkogE|KPb@0wn zlv|55COvNVRj(=vw#6F1b1J(`CF!!AieMC0s^XZ*C_7p}W=@fC8Ito3`ySP^q6QY; zxo@&*9x+)mHuq19hy36t&_%`bjpfN5%C~|24sxeNCbh-Kf>0I|FGtHyb$HGf3vjge z4Fb6@gnWT1^Rc&>%e~R}kcI8m=mNUr;EF`f7%|TXaf!c4?n0f0 z8I%s>3WO?0bmM(w7#Hy0%*@)AOz7#2YAHYc{E!I5EhfA5B;TdF3DumRl071s9=(7b zf%L_!bskItsq-{)+hX$a$Gx_BtAdB7^_Lj0y9ECX17FuL8C&Sxs;;v0QG}*f3{H)M zRLpM0LGW06?LGdER}6H`?I~MMslzDK3AN4nvIFmIh)GByjc!C5&458GiPw~^LxSD% z>t6=s&_Vdv#-fGSaST*xY~xZjRd{k9erH2v*Maah8u&}4lad6;e1#8MyLIQytDspT zYpl$_xwLg*0u^hm!7v+<^|oqvTr!j|1>0jy{%-(6CbjNUqWInLI8#dbbnbeaM;Vqy zHVhdO7(v0-))CzJ6JggKb7rgo^Dmy^2E2iH0^v8u^hFbPmoB)~(+Y`8TW zKJ3+a_E2-xv&B|pO=pMf;X;)7G{ts)kNO8F8jkDtGI1zQTUVW4VQxxVv| zM$ujh7nrNIK2mX)04dH-e%=kaMgNZ?3QpQ3m^5BCK@G`Z(cb0(4c4_xv}Y&_yWPhk zxjCyd?#?j&_O7v?NrzflZZWtak4td9ujVPH&~7HF-`(6!7#<$wj>j8!p2J| zv6|b#Tp03{M>I;FD`|82q!(&`@!~dbv~cNV8sgk<>iW9Y(Q{7{i14$W3jVL5pwkpC zQ=l&>OIdWquO&;5X#zq;3zwVNv<}Ij#PTz)Xc&7yikPbvRIC)AK0xw%Z9=%XtHGiz@X4 zA)u0Cjf%>Y*Bwp(_Y91Xo7&oi_aG1- zA2+;l6}-*71Ksd@g&0%n42xLu&X-28^O0z5y2Nclwa3&ww$jsnH`mI>7mxVt31ApzB_3p_rFzwnMrV$ ztBYduFzOz!Q3*pn;w2FslsBi_-(PtcqKI=rQywvXiyFR)p2_ekg5`!u@lNy5(GioL zfHul(?9WPTktnK&3A2AC3XR-i2DZTfF(jPXd~%fM!PxrvZtt*LMUzMU(?_I!THs28 za~z&AzaZ>-Bg@~>>mNNXLYalMycAf(>aUcAIS>1}T`TSGF=`%Bi0vpGSSk`0>(fsKgcC+^ihAi5 zS_W&;9S7aZQ`o`SI>R)v0+^o&P<#WEFalt@)|z`xrCzfmqs8ugf2sZFH7St4skAV6$dO))kl5fmk+`9NVf7 zaYb}%K0pxsQ)IonMdluy$Q~R&x-|$ks1?kU3Wu3!JH8+i`}?@XMaYz+9$w$DSOXd- zFpPA3Ui5L1X|jI+;m`UO9y~g*rBGgqPvIr_R=J=bbCcR_a)^KqH$6t@QS3i! zlmbA)n8_5nFfo)eC|b)u1bkdLA%~P_ z<$OUNLj-aqOj~J1yhl--FlB>%X*^H$tyJx>>evP*O0e%D4?m;`l?6r>{?eUcuGWv* zH(r@IUcn&Sf)sFW@1L#SLl3mo<&BnS`I^`(h$M0ZMDvbU?2S{L^m-Aqrc`8%nvQpI zO{5C>xO+53O^O7kvOZ8VJGM0^=$(+?m3s5QiH*QkK7d?x+T&tav-J31D$B%YvqEtC z`Hz~$vaUPY%TKnhD>Fk~lHSm*i-cu@S#~4v_6&&u!HU&r)zQEwn;&^#?&|>Psi(9Pz}_ zb93o_K7zYpY%^^_S4B<~=W2Sxv2+%gQTYCT!B5b!OJPLvHRYfzZPm;0`!0y@gnDN5 z=01++q>>3oa~6XLAJLs$>N^aaV0|z11bw%N&-bcRHT!2f9oP`f2R8@!?PX@S zsWO)W_x5-0{!>L3KY+yfKjdHwgj;Kct#}i%{?NEk znOM~EAZ4ZR83HA-@y~+!M0ckHdOxuU+518#kyOQCB*5rGuCyOT*gp=R4Ic!Dj}E4o zuUT=(Dtuwu1v2OpsGwXHYX55e$8haMWxq*|ZI{tGii%eXA@lbbMJ>QD=#{pJ9e;-7 zGwq~85QBgxw!D^%ucuD;4;o^hE7)*VaeIByigybf%R4IZFk*)?AwwjdlKAEGpV!mpnU6H) z;{F!0uPXx|C(b)2%sIs$gUq(yOHgrowN}H^=$56TuOW3h?RaGBISjDJtNfJm%Gd2x zE#Ehka;dg1h7MoUc5)G&Jb0ikg8Tx57d2{bMjDZa2;?{ZCQrOQ1&w!MiXKi${Gx}& zTgGZ9C3VEn`ZAd)I<1da zb?Zr7VLK^T_h8;i+at!R#^Hd?wv1_!IEB+%Gj#~1`57DWzevj?#;oRcpG~)GICJF% zXl@Mu2x(n(F1z6$_;ta+_sO}rqgjL(i)PMskh{4$t5ZgkIAo6T)v~>mpH8_1 ze(lY8b-KirsemCL1fx8b%nF&Vj3u8T4#_aGE|Z$v;Bh@x<(Bs&Z2w?HSrJfCx?!XMOa`z?;4~?5r#TTae*FtJgggU5r)MZ_I;H5pj4T zrNz&s2-$A%@-zO*%qL{&DtZtIT(>dgsW(phXh46)+B5x$OA-4Ld^t@n!N)@{Gejch zMa_slAifiz*>9U5)Gehc1-;T`vIoK<1g)jhvL}3QRlIczcecpSifZ@x8~x+X2F2f^ zh=4HurEAW>D^0|(KkixR!7yvBPrkxO!#)6PtS{%_tnyoa#73btD52*$Nd69ZMNq5T zNPwM3sN~Y9A#O*$f?Pmh7JMXfnoer59ZYPHzDhWQ@dTJb?;Tkb_QXz|a1s8lYwk&2 z!~;(pdg66iavLo?^rth2L*_NnX<;;7E1_&%R?-8+uXLenj)}(6@Eg2a90(mlm3W~7 z?`HmQ`|;)N!=+O5wA$D!CNxh%W7aQGXoXL(jCn$#WUU3$V?+Q=3umC%EKe!&b^tf0 zV>BtpRpybpzwKkNsJ?CB7JvM&AB8%BCSP>LEp*cm%`IShNVt*s=r}ArBq9{`D4m6r zP6kQ&DsyzW$}sZb3*`+*-Grr)!R+X*BrxIlJX?3fU0W#FCWW;pB|LM`g3#X;^=EOx zFfC%{s%<}bZD-L3H2^W2t0Xs3y#i+OYaT}e1DD|w_glt^GK*U&?DwsGod`Yt5z2DO zI*Li|Umyv8>2lwF1~<{r5B_*IfH~|KZ0V1Pw+`eHk4Kg@1{@~_*A>IiBJnP;Fzj&uLZy;D zQL2&H#i9OC>mnN--)=JCV21PR;_$^0$b!=z@N7IF0vXDwW>Wr6`RP^je2fQ!gzJN9 z939*4+S~uGWE)tiO5xi9_#(TPMo(oK5b{s9vsn(teeFhRBTx(2h0^4VzjJPdZI3nE zO_iPB(PXOf1)cdPq37k?f1tx>TE_h`1)dodBkCp2L~I03?J+3K8TPx$VZc7aFX0c9 ziv1Zv#%?;n#7d?UXG*Jek+=uBBuFohMu`B+v9-Yz&1EpIY+6gV+RRT!`49{EWFXSDChEPr9tYcAq=+n1>^{N9|3QyUl}ZW`U{yJfDLOl_ z740HTr&r(?{PCzLdJ`1vk2NwZq1|whgm2!bL~Xs+Fp6m61;WYmv1Os;DpD`R%wZxv zKBQ`SbJkUU3!ZZLn zkUyP6OAP}5$cNJ_(`4|1OQbMF*o*VjUT->hBqN|B{DD=fhM+pf*)meEaOb*-a8-uf zdB$X8pUc1J6qNRM8uSVBqtLTJI_cQ!Keku9*g86|<9NSadjvv*5qs?ThUrvy0%K+_ z8$k(HGMBUgzF6hb6+cCGxf$7~ph&Zjt!@Gr{$@TLZodjv^vU6$O&6ma`1?Ah>RG7zaRZ+`X+T0nn5 zBe*@5^rTp*7&PiOK`+RDUe21@VuUvQlyYs@PhQTw%hb_hh+ z#l{Npk$;9qC~VS;g1O1)?8f-uIwCIGpd^>*B1JI0ypQ#esA8LbfcRaU2Q**WlzoC1 z30v!TJh%UOzW0>!gWlN)R^^B&c)l7>!5h0ymj|_zgoki~D$a*T z=W#hCaWzu(%^V*$kVr90>tpqTDKJ#$bVB)2qKQPJ9cxz6VI%lCQm3q zrZWB>-c>p5^Ed%MwT@j3q7t7?rSbwG6LJvaE!i3L4H1}~;nm2O!G*icrnLW22Y?ax zVK8{{Z`Ll+SklLzz%N4Q@aEZN-oA)q=z3pjC|bcjki=XaqyjPZl7d6toChnxRD_Qb zS?t%)xxTCf1WPFxO6S(D@r~wJpLl8*_j#J0R3yQ%RaGZf8067fY*`LuupXG$g3GZF zWSA?*E*~-rM%+D0?Lbm7{m@{77;2eOZEMddxsI`x)J#o^@wbW0yO_-BrRpho7?ppn zO`(`?hJbL}mo1tr5I=0Ubn+z-P@B(8Tom4kn4l(SU#d>nsc=KcXI8hq8jOFHygyro zx%TniO3=kmT~uCdBG^Zzqhu};BtnXW%UtF+Uf&Y@^NR5X5*KIxE!WrJ~Jct?qp(^0bGB<-44~j{y7Tm;pu96Zgh4ZE%h) zF~ZL!wI_32yhxL-X!qW{7y5^lAfROzqqa5!7G0G}YwiQo@NxO&8pdMQl$^C*y2mYSmx9N?x6;c|8yR~&XuNN`3W=uwU|DkUh3S?If#J5|rz|~- zS#q_2Iqp@$ZZ_|xUDFASe-daJJ>zg#BA9l9C|WK8vMS3ZY3N_yasNquFq#b<$%276*D3wqpmI=VV3`MR3F|ae=+1weBp!Zos z0Y*b3FFV|0?qnv3+RWP#9(9Q}W=qHwbjI-o>nU>zz+Gt^Mj(oxlU*^fG@MBpAwG0* zfMnM2bPFE&OU6cz?iCwMK6yXFuT5>oMnA*=flRiq{m~W6{U$zE&|WqkPwq_VAwG@D zBjkjbkLE3tvVN%DUv7PRLqY9%q&83{dA7~|`gpx9a8HIf`d6qCrVhEszk5k~Gw%5n&r zQ(~)7kDY-QU{!DKAvrxyhZZcht7u@0vsPpe%cV>irY?qqA>DiwrG5e_EdR3Eb`Do{ z^0c}i>H|$&PsY~&=TE3xo6=^Zd!_>QVr56L$2o@uU3E_D-4ZmK*K)x3c2?#yvtER# zY8-}rPzJ1RJpSyym zcuRKu6DU^ptU2z5*g5gMF`KvMVOkgjw=gHAZJvZIg5D{NQU*JGWIT-9JQ`;GA%FWC zl1Q%DdnKd9=;1c3=4?kV*af4XbM0hO%PF%rFyoNDOl+27a;G*&+%E%X3+9>aqK7Vm z=|n~vFmIF#AlwE5S;mbH7~xKeyeQbo7Mn*&+YI!?T&P?aVdO;oZh}ZC4YQ-YJ1}_6 zDF;buos71JMAL$I+W(f#!a52#s(Pqyg&w7@G29lL)bVim6G+B7ey9V${(>RxFJd_R z0<299RyoiD*bow0NiHT7hZa4B&LfKn)6`9{`I8C2$mVRJn3dRrMTZKQC8;QcOKsOL zY*4w-!I+ts%4RXadrWqp*QK#EFc3dDYss*?Bu+~QLOUolNMt{L+7A*hv35P@z~WbL z5rr&L&Wh}+SS{oi7`Ipa`0dD+W#wr5=D0({ct?i#aSUVl4hA(#C$9hae<}^=EY*yO@9X!Grvc ztH0Davo_1f@)K+#xlZRBEQWk01W0LOuXQ{k)1ZEjja1+>hA_Yer)(&;Eto9POw5nWGpm(fa72$Dc4Ym@}* zcDs-}$DM;Xlhk(}g}fs$fTLtB1s_L&?RCf7xhH9Selicu2ar!enCtZ(VHuyw(whFK zDzHl#W)Yp$@lmJ@iT1nwK-T2x(LMOH()xg{rhBk%wdo$CRpW5NcGWc;wBr7s9xKdh z^_H^RaUTv8?3R{;3aqz);kTEt0mwRI^T;8W$2X5cQ4VBMYd(kW#s~b$v=9G=g)V<) z6tJ(J@&ZbeW6%N{+20o=$JG>M38_m$wS>+^6w2wLY?ljOj%RGuox}hA2^+(%+_NAj|0dxm`f%C=#ty zE1jgOm>&1?{ck#3X}!n({~}5506qSGbBgjq^S1L>aA6enHD!|HZcMc-hw42`!IRdv z-id=3{YGukx0@-YDV)|31HO!)khfzxY;>17%D4nySPNYPlh+rOLAbl-oY-dspXkiALA{x4K;TZu&^7 z-ywGE81{ltSnWoX6o;VuMuCZfqO)5@&~F8IAFv9&7b?mYK@UN*lwi|-wXkdG|gm2|(dOg*YNDVz7vbFacJ;>xPvGFv(X+RzQ&x4*rK*+)@T7LlPZB znR$B2+0Dv8=R$@wdjiEur0gaeIime+bjuKiywWkK0yT9`eX+}YM?`j9@ZV}$87E;IFQEgSqJ+d2Fij3nJnI{04qS$ zzZK7U3KNs4+1hiomPA=?GnZcuN`XN$l*Sp$N$f3=*m*3{_D zZ*Ak|_UKp1tzc#8P19%AZxGLSD#|E6o=2)Z1**@XD8bReMb2~CiGOvplJFJ0IrC?m z@*;Py*5^i}vWrM^?4}k*=_&Lwlp@sVG~zmc@ttKLe@LNxOoWJ|(>_^w^XQF6HGwmN@iVXwaY(%c@(g<*#4jPITxkE^?$1)DSQYU(9>naHqbmI=EI<4 zn&*Q-kcw^Dk9a$K0I|IRtzupI!PTGl|IqE0L2-3KyD0ANK?i3bxI>V^-3QkMcL?qd zNpK6nT?Th|cMlfaEw~MK@}6^UeO0&a{kQk-uIknPtghZ0qD(KxE!W>?N0g==-2K$I zc=P&!jZ)#+dmf$C0?f72gelEoC~pOZ(HRuot`YjF;jTIlVZp_G0$9ZbW-l3#A+zgn zx;j+$Rrv#n&#|9Az+KflEc>B8A)LGoWFp>c4fEK4hAo zF;{;K{=uAu#|RA#4m_n~L&47ovc)7PrD>|=2o6M)3i6E!l=?$*Sdj)sNA~MCHTl-& zR^{R{WFO_j%23hN=AuOs?VWX8=#BLsR6=Bo+`V(Ssrv}!^^>0RHPQHOgj5*bytWm% z^*%W+~r(l-A4Z~LL5Ng&cyllf+qx_mAY_CqGT z0qHFkKN4?k=8N}T0P|;*)i@Tqvc$wI97vlfL7=rhhiGG$8^kftmwn!U zK8aasxg+&7-pwXW0H%fJA7*-Hstfvbf8CLxYvmYaoMU{JEWPZBmHBl=j2F9CreyO= z=hp{BM`}(dpyJ%5Z&I}st=$m%GWmLzLU3UI$w+lSH6A_x^2@J(l8Hz$sX!psc(WDn zK`!F3*oD4bPcK!r&Y_g0mU~iu?2R&-UFS{INyWdv#MVFNtSi4HZ;VeIcL#!H!BVgc!zJOB3whkThoOwlCa zzy5H2v;zGsm2$9@EHvO@e$Q#-UQaO}O}HsXd3`tB>%z#Tf*3q^iHaBSilg@_p{L^l zIy*(DCz9m3Wpu%Jqc?{;L?0-#aUDUPb8>j{iZ0DgP@-=RecCngDWw>HJ6haI$ep?1 znT^H~Z~SihDWd2~I9~=Xq!NE5ex7+c&?&m1jJ1Wi8qiuHFd(Xm_wSf!k5*<#L4x)D zdGpg1c2R}_R<8tx^#1uoxlVP$?n44RLHoXb=5Wou%%Z$WK9fKcVY`==1M4eien zCl9rPCt*1_!W&pm%nmsg$UdByp|ZnI*WfeGD{UU5_$}(}JY5nL zKiJe@L&s$@n}&v0Be-AcRF6e-k}ehT0+jolXbNj~R8LYfdS`ltFDlY)*9sPYiHX5gy$472w_-`CJ|0;L^5e&S`SdiavBGk=>+=Lpdnt{ zmjfHkVW92Mg)p{iKZ#`)!QELtUD~?v*Aoa&%57p8d*Y=9UKbwqInU=W6-zdy{PK@{ zsGHxM7i2}OE!jHSq#e`qC zJ5)pcXNJdQ7#klR(;}eBVL9$|_IS>&(-*+5D50v4%yiQ^IbA1N#}38V11w74ggQOt z7O*#NZ|eglPzYr`bLq$#$Mn2L>#T_B^XOUvcOYLy@5rr1crO6{Y^#_u-?HPY{OJ4C zW%QX-yd0W&7BcAkGQuvT1kf~RL~H|MI#s{;%k{;{KLSz37`K1Yy`+b2CSLmDy`|xv zxOT&bZVU99I{%%h|B?97Q*6(~eTwlz=GKanSaF^x{q`ZWke6zw7Oeb@UbX?iGLS_%(+OT0QU-eUn+dd1f2I?`ByhM|+aY^1r)MaqUWQm9O zSG$YK4R3RH#cm(PfV$pZ>B^eZz4r&3e5%CDFL(h8ebY4L?mpp}_6OHLtR;&|ZYDnY zAD3JAzpQ98S3dvlF0u(AroK&?3WHKJw^SYa4-SuFF}j7kinvbMkcgQGqi^TaMHIFU z*q(hj5g8vS&0JDf5oQs1`$l*t(J+daWxZ0)Y?p>TV0l|yEfsl$`RV1lP6XGaL3QHT zq0-Wl$%Fp3OGSvaBn~7zB9fRsYv>N#n!j@m(qSA z^T$B`^E;_(T=l%h$?5}QiVJZQftYQSn<-l2qVF8jAn46MS8q`f)g`ZKSIYdzSVZgl z=}OgpouVA#__cTK@qdENwP(}erSw76=$#r>jA8p<24#ksBwpI#1t|7i$UlCODHX04 zu7Qs|geg;7@tDGDVH(AA68(t!kaR$Oe%BFo<34~I_xA^#->eISO6_bc1iz-EYrw(XT%V?Orbmrgtu~9$i%F?g- zx6;X7(Q`V?^bze!DJ~|%y&F=;YfSzn8h`W)Ln6;|A6y!+)uEgUV$#QlOs{xFCIz<{ z3aEW-Od(vSY0s3%&zy{eKj17LC;=p)Bq*OsU(fu?H5um(h=NNd|0EZKoV#YtDTM(j z8sa#GLAeURn_B&)qQeL}EF_e^N*GW|tZPe_S-z0CSz{6&&wUvp+IFlswOp`7JX|;c z5eNyDlkey1u}{vziyHy%t$~SO1}Br1LhnQWLn*Q2aCC$f0Kr zTO;)!<1PIciVz}TXlg%|tZ8TDmEHy48yZns7KxTs#Y4I-XFc?LNV^18m7Yw!P05WH7FXUoUFXMf2jME1#IDnvfbZH zi)xODsKbhLHiy!LbfkbaayAdl^zCW+#!dKE>;y6}L?E<}g*9UBS=!~n6zYrH0GFUw zqhApL@lnkPv}O{^!2m>B5SmN~fiOnkDplB@R6Yh*z?dP87>G1X_!q5cuaW9oD&Bd6wX1Si=t7ZRy5u_W8}?A zy!hdqFAneBGeqQ`<`3RS1mJ&EoI+D-xn7C*kbkyC2cZ##5PVcp#6c6H@Z=q=P;1&ka%E3lXCBYEGzOK-+IVa3AF*GS@ z`NC-334(UZvc*85r4W@w7NGT24@CeOPDU!}07K|@Ja6lx*!!E<+r3!M>z9_iw`-HP z3)h>a?$`0a_rU4*yML7G_o?m}SF*j0oR+`um`gojgICs%1*wj;pMdYh?v~mvFep#N zXRR7*;Y@>jVRpg4%){gK*D`hl8rQKn&_iMC0f<_zEOXE!|78_=uoc3Et>>rT-*oVQ zG$Vk`ByySMK;ZZg2mnzMg!VavAP51-z$n+-->`7WLD>BEO6f8_D0R}Q`LRYQZg%gp zd#1Lprcf4e#AS|cZ==5VmNkb%p;r>cDKwJVNZl(b?Ioy7Yjfn2cZds5I7?Mx6)Ec1&V1l?DD0%Er1LX8@|}y znlSeoG!20M4=wyfHpF1L+97Ge!YzPYZ*VcMfuOspeOzd8s56T*PN980T?6I~(VRaZ zeRx*)=DHrKy3SH&nFS8P%r*!h2Mdaa3jrV^fY4Av2yhU9fPcbJ{^Hy*?3;gX-!r?A zWnE{x)BHwc&ZvcRpJx9P0(GW4@5JHTDivCAnsvp|s<^vHWpJGyL}=)7BYBmCsRMc2@Dh+r0lH}ufazwRs@9&)_l>Cw=-o4@8>k*SKKE!xmR_Wn>vy7%u3Wdkm zrf|ffxQPMI!er@=(|iWA-T7h1ulKTTkIrvOV?;r&op4 zjIEx;9q2)_VJ`8UsV3VKJGbz2q48t`pS{w%k5Bng2bN|{d*`exDn!{4icWYC z?`tT%nvWgalAOe-I>vYNS?453gM1S_0>6a$WeHnJ{=E#7=ZZUqcQMk@5=dYzoISDk z`n^q1cpA?ln&@73Lgep_%{FL*gY2@aVc6jIW5ZBDr&4jtlx5)PpcnU!UNu7-Tz?5O zz#AXX17r-e9<)Y$;LTM`7#AJu!W04w76u)a=lUvF(t?ka=ZFF7wWtBY{+F4n*lwy54N z^`bmACbGl39`OjBSg#NjM3E80ePwS7St|cg6}`h}Fjxj#d(#Bg!#i*Wj^}iwS#9~N zx4Cnp?@!Lf4;Ja>>WV5E!GTgMIb#Jz*^+l&(N23kl3$_?8wg$|72OwwLIW@L;JLHD zZ2EmAuc0hVI{^%G=>StBzG#KHkYLOp0f|}^${2yKNFq0c@E=2KgYFxf{?<}d&QF5O zDI5fC2aCtnuYSVxTnckGy0S3!wd2%0@0@5UU`2nf(UFaekl{{uw7HvgkbC-JwR`Kk z%>RxiZ6WyiK~-k?7fddS9YM-`rCXd5xp=pF;9N?ZxTTkhTYKKgSwi@R3)*SQzU3pL zYyKPZWXm3`>UrA!_g(V@L5fu7CjFI(`epE|*)YhJrSfofv}I~LSjL-H*FU4pY8lEM zW{f|F52_F)h+U6)p=Zw4UNeR}+<6+3B2U;4uz$bgJXS@5fRS~26tD=_wYBePzai=0 zY0DzF3kkWa?lb*9E5N5W9Yg2T=^lCb^{{dNgarsO~qewf^ zo`0nB#^=BF$r9-yD<$4)QlL)wYfEkg*Zj}cJdZD}qnHoKja*x0BZ?HY?PTJPBsiH! zz)}niai%1}6^m!bpf(P;QgjV*qC!(JP z{p?R>xddyF#>J8Job*)QJNEa@^rrzkHAe}>r^9F1nS?9KQejQjG)X}NAH5$pl@r2e zg46s(@^Aw-P23LJoUy^!eezs#y_)fGSpY;K5Sm*E!4U#57ga+Xzc7eF4puoHE**eK z1^Ql#`hP%KXtJ~@D=#+}3AQR9t<`E6+tJXGo@gS=3B$~|uCr^>TY_x#3C2C>0qun+m|l1ORKB`MikddiNs{}d)iwU6+*HDIG$o`^XMbXn50!@Ug zxJi(kDNzlPbaI}AmGx#9AjQfUnZ6(V7;w0|cU8Fon;ua!?jGnU-i6F!BiO4T#G^eY zt=N(Mm&`&^SCGVh-6j%GLcB*`ka~yEYBw()?zfDZdV$z%jZC$G1`B@S#_tFnv74vr ztVsmOJ~7|BV*KZWe<GrCqyOG>#P!h0XJ=PvQ)99ddE(2zv@k;ML_Cb{=y;$9_?HzIB}pksi}ML$=vHN zQduj9GFCd@*ehNR-BF_@Ubq$TEW>%P1zdb)%;EA+ztGCECW#OxciPD4ioFLFwS>y4 zDIBFRinNv3fK1oW{pzeZkL2~qMnti5?m}D=iSQTGf}6CRN0GP&blw}$OM`EyAx?bH zm}IyvJA5<=6AbeqY;RhvAtB#=v@)G`K(BeiXBDcU%br`yQBuULuUHKOcjdpokkmO< zN+UXLfVh-NIn@?qhaL&8!%^zUOz+u#Y;`i@rXj@K6w+U^y%2eYAOI}Et9W++7;Nq=k2|uPC9EoPL zswJ4bt-l^B>_$4^veqI=T>0IgTI!uqx@1pWNw6aID?2mmw1B+H$)$1Kf>W1%+b%Ij z1SwV8Dqye%3D|-Ml%~|_v$yTY;vx0W%BEGaQawgi0*4bN7@*#*<|8SADJU=Xn~v9= zPmUI*E{&=&M)-b(dZWq1vCt#WW-+-P!&&vsZ$Gxl@%TFzD_)cYUr>qW6+q9E8at3} ztaw4ISBBwM<3CdM$5TW77KJX-<;yrz4x57`&|=Jw+OOK^OPu=3*_8i3rT4*`XTu&wJ-14OWg6?V~Ql9EuUE zS2>re%?gf&A1F_@r#|9xDK}i?bQ>r<@R#l}0xG)=ly_IuqDy6|-MQ*`A{NKWHzI?2 z5v&Lbsjyq_2J6W1GpsWBdn);PROhuSG^zko`JF`gEf>AS`pTE#evGIsR6sm>?G?|y zv4Z8F~i?6F%daJ7f6wg3am|<)YCM!e`o|ClY51mMdb)fHw{%ZKj^-FZQ3c8jss)=4mMhes3 zL2cbRu3%|x%&JZ`9l^j|-%VI^il;Xtqwo{uc@NT849>`X2z4f#B_cR5%vp9U;>#bD zzC*;5v)m*XeKYOaFumN^!$xTSMA`~hz^lqgy3U zP71@ZrO=hzY*5M(y{6d8mjqMI%wx2<<(p|_vFw;dtEe06TiKOyBxV}5LZHJC{+z2E zh>ZWPTesGBr9FfeV-q?~8FqTRG|&(tibDMAab2ll=+&_L@keWp-`)QCBBFyBrueC9 z{|n+U-q!EgxV?dii}RWdC>@raa5KMV74&x227}h$v*3%z*$H~fX_}@@y8ZMW_1WH5 z#Z}>AuG}YH4nNX??LUvw#H_(z6-(iiPcUP-a?>t) z-4FfW(SL%_EH#b3x)E`-b)<=Sv~76ZYHTjB4YCe`7ve~SY}qyfv=w7#OT@W~_rl58 zdTN~Qj)E%$PPB#alH7QV?lP-sIf3%9g&l@zpB!}kdhDEO!pEFfc6R?Q4mRR63VVU+*qB(&AO*{w ze7|}PaRO3ByB{yLfYlUPaG%4(*CbRgL(mcv73l?m6E~k6m02Me?1Dd~IUz zxh;j{cpSNx*xZvUp}&4RQ=f#jX+4B&^37h09wp0-X0GH!NT(jYAU~FUbwK5VG9deQ z{g%9CX9!x_Ued|}n%a6Fb=?b8OgOc1ZjGhvJUUfl^^}h-FS@fBxu+ZDG|V;qnd9@p zk^LKd$qg%wyOE|>E}O2vImxQ`7vd%(php#qh0qsHA*n;|dYW!SG&Wf(#7&4=r7&Rs zO?#rD=1_Ob>c>FGk(JN}!b6#8;ehdf?L5Fbc+Zi2kmvHMX zP#tMZ#W%MJg2TI3LOKS}-Iy0ZqS7G1xvAv-jZpIK3&nwf)xi7s=1Z}!i1ZzlapJ*- zT#SLgX-T>~-O0)JhVeETk2S(tR!GZv^plRiM64hB87;KWO+}^0gD3SlHrDSsg*8@3 zlwW9MWFfs-ai~xM5D-E+b6H9L_~CeNCs5*LEG4!&35bn3q}b{mgs zVa}qRwR4(HQ(vV5v|h%7Cu;CW_%xrXxm=GaRV#)v;X6?JxcJ;DWHcL2}aAVbpR%r0T(8+ao=Q*VtmMd1R4tI(sZg3@N)iA&Rmpwe)m3IcRez;abM;?jynI$ z)YYLYO7ar-LxJ-@_%O^u!=pcRP1VB`=l+|H{@-B?b5SW4q43yY4Ou(~Nf3$#3~nJz zzQQp|o-}G~7WWhqI|d3}2!R{|Fdjt%fLo{=4H(o#0FH~w>D`0I>T*(7>!_kbJdB~Q z_n=d5?mQ^}=$hSOk?fmy zm%F{O;xG!7{of1tXJZa<%M|R~8dl37@I2nOpTb@tqI_UvZzx05FFvZ0?v2ShBB}-| zM9R~swFD~7Y4>|wSZnZbc(`W)NqvB|#&@huvPyMnmEI7V;0FCP82uF z%hiIaT0~MYjetDp{RP3OAvu3j07<8EI3_1)cjXLm2-Sm4uGcvp&X_s_L$#cwt?3Fq z)a)>Xz!V86P%)Qa;4m0sV3Wr~uFdub;}Q|5i>^_TDOW~QwL*RW3TFkTimPjb_lXcw zn`c=LL>?0v;#4Bu)h!Z8xw}uYD)o_O)3=bhrQFMMXS_J;&|>!;R@um-tgO=Ji&L-` zu%|y??AppziXtV~bhGE@(*L;1j;4cw6b4h_yOw-&CsSs?7 zn`2!P^Jg&Cx-B~p@bYb@)%Fx0b3<|CY>P$puux*2JYxzI>oyw7{f(|KKS_t!zO7$F zR%ohISh!}ZZ|ljB-gT>NFGU}3a3}v|oxub+QhCx5B|ZA)PyoLVQDHmM>MF&wQ93Xu zLjJHdYI!xmv<2_ViYgL)GO|DWu25SEwFPfV4S93*40q=WE{ z)_wT7m^Ne&Gn>W}Yc$@T(d|mP$aj@)q3JZwoqI6(1ATR13Gt;34*W&ktT~%L+{OWB z8?%u$7x~+rH;nG~NAcuYhBOG4N6hTpsBtdXjVO%D?kmr8Td0A>NyUSNU@h&r9k?V8 z@9!F(VUsLO$mvn8pK?L>iUTUj?@d|W_*i|jb)4{$e2E%aaa{pk%s$s)_2S6~?JaFR zfb%4CojxwnGcNp|k4_Eg0Rm6xtz9ylL~CkJePUX9=i9{_)PXjNwTi%;4V0#)m8LK@*X5|pv` z9=3TOK6H5}N}&~bc%S_;g>a*_3l3A1>st-pYt84M%LZwIO?Hu)No+pBzG4>mN&o0t zjxtD;I>@03`duRT!>VlVOF#S(Byf*&bW@91Qdau%qeF2N-#bF*3s3BqHWvpr{g7i1 zzAk1XSJ~K;4hN)WF2+<6S&=E8P!{r3`oA-%CN)aIfTM`Qp*5OXUbyJfw8Ds4*Yb}* zUo>6AUvRy?ObpucieUo-~3wMgkPL`--JA;ww@vwAyd4(%abk0Gbboo5+Y z7!^R#YP#Gxj{fqvu*Z%6hJXBB(rz>&JC=>`_N$1l^h=PEQMX-{<1j5+EkHM2tSq`Y}(1@Ntq2d&o#yrg<~5XY@u6n`o z@bSkKya@ct@DgZ!_#?DmRKW$yVANE;9IuvMI=(% zY5eePl1MT#!UQ;LrzN?-#^4yj1xZpE@+(jniSkn-{(Fhs;4Wo-$T^8UqwU^@_gHOD z46EYqT~T~;w&u|M+2$-)n2AVrXy;c+p052%@g!|V!DU8l(*?qhF*2Gh!jnnY6GEBM z*q_?#`m91MCzs&ccrLIz$f*XPiGb#Kqr0ew;|h6YZ;=**qOnAYW5cxjzqC`NQjPvL zYdxndhj8;1Xw1wl^j1uzjlmr|BL_)$Q}|niX#FsBe(ZeZ6|ShD7HwX%=g(y;p(=BSA?bp$d(eLj{Vy* ztU&+mGBPr-2VKLIs4#5|VDYS9B;~V6K$j$lLad53^Q*C!+%Xeny43U;I&$7(L(w*< zCG?#I=2uSuEEfpPJcM9G>$o&~>mxu74+(}Igr*QekbE==RHHb_H|^~+4U$44TR?BV zJ`*U%()*dL02DX@5oc{m2~y_bG9dDV5LhDsXHYb9)r+tzbs5O={#G=4W@R>p0C?xp>KokHg47jh6&cjm8<^hPzq29uX zxl&Zx%N|r?lCZTLk^swUEx$bCwzi`!P=l)>H91!r`hP?Cw_Fsvj{Hel`Fy|x7Ek7C zavA8X&F2SZqb^-uM|Adm3Y$a0m;A8h-d3+UcV;aDA*0@pEu9VqEdZ5b@gg*{*#<4P zEVyjFb8DymP|&aIY(Xkp(6RQi#;pqjKN|eZZHf2|?RwJZy|CH3;1z$SGxg`_;Ax1X z3*P02!YyvO-VzNvEK{2R9c-;~p?#w`*MRzFSdDV1^=&7r3S5x>;_*vplF!B>nyZ7& zk)Hh=A*9=b6~V{7#XgG&!3ujE+WdY)_5KtW==&7+{^}HHAt{%+bFnMtw-gZP>P)&` zKpn&GHcR^DeX09p*dzj6==H0rNbH*uF%nLS%B9dmph!+R)Uh|wy6jEg8^|Q4`A>H(kLUkEM69+l`Mq>MU#6EP zRPA5CD1(&mhE&`5vTpT}!u;3out4c->_1H=v5xl04m)MVPdriEUW=uY#FeCrhCYfU z&L0Opq&5!oX$5Ozu-Mq`1S_eFUDaW>RtBUR)(#-n8nkO}r>M*aoSZWG7tA5Qkd+vl zQT~p%S*8%rnKSyWg^b!R-9tPoE)V52Mt|%Z31Y_!NF+!Lx9d&DNkg%gLytbFZ*Y%q zdopfn?{bwGJv=Aaw3>kiE&;LyG3thmc*mxX9ZyONMZnxq3i{ipf8q(yewto~5Zn+I z<`yVy@7X{Zn%z-rP#qdO$=oShN@sYFZ#fr~K0`I4y71?!%sAT>bq^^q z`$uRhEwXV^9w_o7w7PR-V2xmE#Frc5jiK;#MMG*7x2OBv@+G0&+S?7p2ok48-?sVz zX`V-LdbAy3TJf?2CzxYTrhZm%(w4s1-zRKvHl4U z)5xXAAFOXkRVlbP>u1vTH{|@W>OC>2*2A1xQ@C7)EB?Hxzp$paeuR^_`wEjZU!MIt zH(Uz<(HVrcb=w-FrePlog!Xebpl1oeW@2E6r+U&*jztKxnhM^ls5~HhZ8O4l*p$IK zAuL>|8F~3)O|aaPHt*%rTb4_*bHD@{$HR335Pd-3Q_wVm2n$Jy0E0}3KpCJMtW-Q) z6aWziga!y95Jv#sGsx+kgT8a3YW#MKuPZJoYATkUd8V|3l7Bi(Tvy48^%8r_6We`QjJ(q(%tm2^8^-w z02?8-k=vD6@ka^>Q})o+t`cpSVZ#FB2B@&t+b5V$X2d=Bn*&)3YHgr1e&H5_97sMs zBoTo4U$2SY6rZkDiz53IHrt~dACdt;Ps3U^QpG@O{V^n$Zlb8eywotY=a18cTsf=Ac3%G5y#U3Bz$Zw+ z&DQueQp3%r$Kh%-1Hp|SuamD z?baf>iwpQPHRfcKec)Eje`V}caGRwdtAK~tfB1#&V%M4~|Mp9A!1uXnKGkd5Vbcn3fT$uVH1PxlpM_IIYc>4A{m`J*Pl`x9&aH-4`mW49$`qM@|crx%GL1?-m z1T_diQ&f$ACCQ}V)*!j=Z~sWevrSp$KY-uaZ}!V%x&P ze6(~u*Yx%vVZXT1=-phXQ4jS%Y_Md?oH!Awp>VobrQ%k&q9*##DxY|+b)01>A5bD3*{`#s4;~e<>qEi# z{wT>mjNEmbZ7J{5Q{9r&1C_-qrz~+IG!aQ_O7;-*c9pre&X88~cz6RglxHymn!SG8 z;H)iGk}YlJ7r{7u(9o&0=_jC>2XM-PKF5Dfiw^G*YtY)_fwNA+U5e27BS;vT4945-g1R{bcb2*Ad)3n00+DRZWQV zCNZ0Kdla)42e$T5D&zVm0s~Xa(68+!()T!1h-h@w=JK*7XdF!W`$|+}{Iii<_IOu& zq|0fh4a{HUode!5!l)}W{s6sTe98`Z5FY1`Zn8fUMxd%OHRri1sA?$j!-jG7gh=H# zKERt>F>!wwz`U%Bzl1Ax(x{>1^o6aO0Nzed!O&O45#!79v{Pp{$Pj}w zAa!)=!AO6abN;vzO8Y+`w`#`ZR?@PK?CtO6vk;LVPhdqF*$2>{IM;bfojo*jd$YLa zK|@((M_bn@^tXXo+|ryH^6C@WGQDM$JNx>}HZns&8}U2T1T70z2xl}-0Qul7L*Z*H zQe>49anPPhaD8(_lgz?o=BR#%*5My^96hjrvBBvaP<)jXOK17malkyszI!UzNczhz z_8Db=H^0f#7cLFzIEUDZxZy`RA=+cWrM zdF$AAOw%ZLMV;g|K;{BE{pBMwS_`;_k=Z5y8r_V+;wa}IMc$AAzKRq3tL5YYBU><) zvZhU|$8&wo_pFp01+tH35PqjF*3ng~UK7b%;+@qsOpB@=MZU+kL~aX>4+|8;dC!@B_05 z_QG@iT_`8tW(^QYnmslzqaKu2b@Dhr*qJLIq1ZAq^95Q3pztDBkG$uS%>CYQy;yfZJg2sScaw`vL4j~JDgUg;N-3Q{B z{bur^1nnORmz6WdY*{g4RfJUg&3O7JYkq>zra}npND6bA<$BfPKd=6aRRo}JT55VY zY40gx_48K*!zKS+yEDq&J@<9~syXnf(&O5$4Ayv@vUN-scGr6!je8FW955)c$igR# zhTpG}vFj1~!3@UvdOj^v&+H7mX!RiVGyQtKK^86o0zKO5w=5^CfJp6;F7q))t0bw& z>LaV&)qdfkQ&x{%G;g{ly-&klu*$~ff^QLm3P9~(g|aYEk|TRuTGP`9^ZDyxQ+<%f z?3ZhnDnT{sZgu10M%r5n;NcHE{Q|@jDu`{MgW{h7Kt30f9t0yNTUl_taw^zBZ zkX@cvs!ikve0J!5R=1M;;5i0Pw)%rMer*0&b8f)us-E|Ws4@DS-@kTP=z9sVC)vYN z(f#7BH&O?SC*!gI==b`*l1h%q*VM8dq5Qh}4F%h_hfvU^*1SLIKu?EFblQ*9pjO<^ zRSNJE1a7^(3rA1+p^c(Z&#Td-FjaJ@3dwhlIpR$xYVfwweVYy;!(N~1FjOpdU}cREmv}9 za9(2T9zD4FMWW-YhDl?8GencX?~qVfY)ukSO0Roah5Mr;=Mh$oipa)`Xm8VtXYfoqsD)$Wp>sW=(wIKi?Q8Y{mhDKfO$h0Ubn z-T@uENkcledqRbV=KT zvrz0$su3E(c?MFSXG(+f>kif=FNvU5MG+~P`Qa!5L?E9g+UA;-XE4O||I6FLD z`-_aeH!A$Zog$U#tinE6D2AuvsM2CAc>!^I!#P{72iL6j4HP4jVzgjuRCvbM8F+5Q zdyV~ppKuh39y<6WxAFrS__**3!}zlo!#{9j%KZnO2?;oYt`Wo#P4oDhOi-gCE$(E# z$fo^vi)KqB9q-Cn%%jGxN~nKD!mMx2g0783#B>S+s-DY7kgbYJOJ1{jF&Adg=s8Zb{=U#Qe6#4H{!oI;utt^LC zDPe{7lOf>QVSgBGyDs7nN0rdr&q?LPE8GTE9lfM8m9$t+WVnfXx*?x^E7}u=s9fyF z^}mBm9`T!0ivE(A?VFMy*YA!-Tq$9z=h*TUf|Qz-^1XRPZWkV&U}zx)>*-BenLq_#akq1-WiYj%@VXXQ58D9(z1?08DF>4{Z zB7cls@t!NY)pkA6ug=ouPboh_UJ-tbi6Gm@3I7YvA9U-JtV8048&|#sSpP1`9gPE4x{C=gsBZY=d}BtNmBA2RSI`XqlmJs;Ajm#I z!AIX-d~O<4j^lo7hb$CIK!*gBtI*(!xgh!!BUfe->WM4%~}Mh$);xke~KywHW52_qgP|DZymBbTKo5t1 z3xjn0HlySqPUiBY+9O-ikHhk0J)-g4_4ScMsP*Jyh<00+{#@z~xws3dMHl#;rF+A) z!je1UZ_RwCE_;a4lCsi_U;Mf}G4NPX5ciXws;Y^#an{9iIFmx15xL(Vee?D$^8g#& zAav6KlyJ9gH_!hMXN#ZA-D@-h_Cm z-6%&u%i`>A_sbW7%*jVhCV*w&0 zM--2VDs(y#k+XaOEfUH6!o2p-7}K)GClAdDXzYUKGeUop zz1dGNd+b>b$wcDZZEs|Jk4}@Z66AILr^1A5zJqTRTd0v)?o2lR!$`#v1vxsIg^-<* z+waTUx#QiG$*p8p!N4Zi0e2+3ld3|9e6C&qRY@RLeEkNIsnH^*J z&BBl!IRQJZ)a9!L5c8GlW6mSrx_dQ+pNynIvg~#B!v9`J0*Mx!`d=m{)$rGu)JhDz zLEjwAo{Ls&krKb}A-V^$L$YO;f6(!a8yUDQk_h_AT6i*@LJ7`* zR2IHC3@(Zqs-b3Tw7fw1;Dnm5GMQRs5v(fd5%97J8OeoPOs+|Z$>$C&M9B!i+!@c_ zR_b`W8XK^`Rtxl-=Ue2aDXxz(6%y^vD2(7dGXWsR+pR6d{n}0APtLeJ>$9!dTG$AP z3*TXAJ{9au|GKv#k?I3qr@J(j1EVQ{Dbj|k+O_$E@reiC+03roHq@=Dcr zBbM^F_7Fqn?z~cfF1(Vyw9&vmDrx$nAh@#YXW0a)x?n6r$u(`cG7D&?h`O&ZBputfZQDDxZQHgxwr$&XI!4E~jSg=5d7pFcIp4V7xc|&OYwa4V z*3@sJinxe<=)0;6IuJf6MMfV!sJH-Rtdj-RFP95zR}tSJA+!(w@9fT#d5*Cj0ABJ@ z2t^PwWyqHIqbjb8h$@8HejB@5pls+2rX+s-c}i>?TnI1}R+>cLMNTdYyVg)yNEJFe zj$YZ=dLC$n>w?CgIIvwW);wyEf&NAKpppWR$w5$plY;Zxo)RjxC_g|nl>vCepkCo7 z<^ACJ)ZzlE;v5bh5^zxo<=at`A{IcYUC^-UoQC)$F(>=zaLi!*#X_f|B1C5~q10W1 zrCoJ17y2MKIneb?Nc;jz$BZSKZ7GGGLczHV7UnFn68LU@75Jd40+7`K$mpOH^pFaA z$c4S+;sR2!0jc;yn4lDD5DK-3g}ub$-$Y^qLh+3sz$i+J%*!rXMd3fjDqgY2mX$Zq zNkd26d|7a+?q`(Gu=U6ShfsMB3F+a~mITJ@VHJ{{lj=cp!}1F0Tzm!5eG{IRvBaCQo24<)irV?5-NCAcZuByj!U zl2+eGaH={2Qs?KziwYkB8f5S-PmQ1aFRL-G!`S%B3X=5M=O&K}BQZqAy~IGl0eG~F z!NvSS*a4Z_AqbOj;Ks?zi;1t;QZ;NJ34lgTY zl_Eh_gcwp1WQdiKqL0wt*ElCc?f@U66|9StJD*c%)>yk3XHGo!nh6oNZX__ov>v57 z1jqj22y#328DUQi!vN#EP8iDv{ryE7Z8Qgv#JiN67RLSh(R~h;U9iH8d;u!#w$^m6 zbk~lWgDW6n;Vt$9%{*3@f-Gy{1(BgSII@sct5Bj_UJF?f^7Qiitc79zt&pSAaCdcV zwLyVz@mROKRN;RUrRoE$xAug$lvJa6n$?)!5Hm4+lM z0%!3bie;+;Py#cI5+G5(f_5qM*u}{&V&&clk$b?477-$JgACyEQU%CqBIG9V6H!IT zP2d5Mgvd*y~iSp|EHVnzlrE??>w8Fu?qg zg4iBXEF&+d(zB7hKFVbn(db5kgSLwD(s7@Cht`K$B zj$xoS^2G=2qUAk{ko!<1D$bwLOGYuwe280r?Ja{4p{OxFp`InBwB&;QXP#Z;bbx{; zD|3XicS4u@s8&WZlV;r|6T!n79FuP7s(Eb%o#Zm!b4XjzK1FVwByqU7=v+ndX!;S$ zcNVBgl^&0-m^EUonA@Rl>`<;Dy>em&?&!FIMZI%khFrocq9kaSBF_y#o{jh)>As8( zYjJ{nk)pi4(PoM+UOvDtc=HuO1Yd?8f)->5{4)Mun?D}?2_9|*cR)lOwZzL?AEAJ&2rfr9wHM<6Cywm<@sl3zp-s1d$)Uj>oUf!)l^rALb zsqUXzWiw8AaTl*hPLsLqTIIGU5f6mnixN?OmLYxVlHa6H|3VE-iX6DewS(Ty z$cW2#Z>xc2k}%zGB3PFuXSFoP^%%X=S-k$q11Voz9WX_6o6pTvXiq6s5a5Q_>%{V8 z+7tXZGOj$+^vo-3)u%H%@lx=|FGZ$otFl5C# zM8kMAuB?KEZnZ;^1I=_p?DEEIYatDlGNW}Ek`|`!K{BEGfsb(s{|D|OQ}>K5p~3{V zgYCFOzmpSW$+J^k!Xb1sCxt7T-Y*wE$6Tym-HDhq$|?~F#1z94g7ym1M%$`7J)`QgIH4Zzbk(rLDzrE20F>uuE~*u z!P6EEv&J`KU_lItvdZTwU={-F-yhJGI9M3Bjvhjg4>6PMo210P_MC5lAD@qn>|kYJ zk9MsYXYJSC`Myv`tGBGB-owYqg z#Bu=7Vb5tLXcsPznS#c+CY1%NT?=PxnBLlcQ|MCFK@B%r=KZraL+4UJD{@_W<2-(A z|B!J5=1DWqRYd3Glqt9}lFQ0bLDM@a@|vx}F?+36GFH>Q&1#SHe9whAjG7qv5<-M?kO58}y9oI(JYb$Mu~>xs z>fI32mQ=B_UG3PBb>c{y6mlO5Tf5vbU=5Nd#a9~;9Zbu?D2Ns@6HIuY%xKXtWld+! zT=;#E2HCzPF=3M97x_~~jd5{8p^J04C^NTH(LT{^hul3aau<}ymEiwm|D=X&mRvj3 zZ61L%p7I2K{5s7{X!e+P)=N<^?2>ee+zifl%{%TYZtXysu&QQ#GSj6L`cuR>b;*_3 zmv$+lMY$)jrC<_hCzmQU+Xt$EMGGGR6}0<3ua%$tf3oQFc72IPv@U(vp(Z`zsEr5^mz3{$ajtuK{COu2>=H?;Ii@j~X-PH}%DTW(BYj9yeW zG8Wd@yS`aj+5i42JmkF>++5F6VpKoLDb%JBg3)sB;ogK6E&@n;bD>7=;uQKm-8dw% zc~*LChTr7V8?)>RXX5@2)6V}@C%OMVN`HJQFxY1Xqw{G?#LSQwxS02@^hMW-Ok-ZJ zc3ORCn}DnQK3VABEI&!SJSPf1#F4IpHAMcGpgGW32gyO>f0?-`8hzf3$R>`t228>c zSKvK@!d!dWr{VGkZH{ELF^xL$*NsLri8?WI>0o)}8suHLf9XGoeeMk5zvjST-=&EazM19cz%wT14wd=Ue_m+LFnd{zbGUn+FuF0;r=7Xc*4Iz{zoubi~sp&M7}0K&xKqJ9M>V@8=)&~inlq)a+1$0uE!U1}3S6OW!~3G_n_&y+Q5_$g zxp>qQyDSPZ09@-0M816o<0zzCXFpJtTub?d9gy^RFaz3Lq96=1Z40^XdY;kgPRr5} zccAcaHu7U%o)?XNd6m+;V0pLmMbr9cI7*F8riLP`#VLh~$nLVf*o)hLX=I~v-i zT$B~+#|ikPB&ob5eaNsfDEp1g^@}L0AzF*9b#@V{Sr!arugsGC)AR)8By5zgFC= z=v-|bEFH~^jh!6n{`p~~b+fk0OpuOTqlXW<3jP4g>nIq-8(SSzXU(AA(4^Q9jEUg> zDMCGwlt>5o^b~^~quobDH8Qu#Gd*nDOyj|ImVDI9V$_y+F_VIDno}xYOT520lDy{+ zzss)>w&Wx~fx!08^nGXKLho>f@goyI){?-w`V+uFhBeb4Ed_^jxiD<8bF*lZdPWmw z!Ct6|8Z&}a9DI75CimMDy|_9ls3?vLCW}vqy0PEzy;{DjW2UhatR#5j@>LvjgUFyt z<3JWT5s@ZxS=zKxt)=wOs3O7G>~7eN1g&|<6=%ZQem*bO-B6R;x~fd6vDd_g}LIm(zYQ41_C5FAUw+x<8P7TM7d-&NV&(!`9(^!eDj^}dB1Dvc?bSv!`@${^L@Xw%?tTN zD1V-y108=mNb%kE{rpp7tn>c&&!bH@s~TyGiOc`c3~)L)*y zPv73m^upa<3PA&z_r~EL=&8+yNr3fAWcbnUie{tCG8df;O3s0*zxjvE9jU%&b88Kc z0d)?6ru+A1tixwRG5?bUxlo=>o_pq?1#}i8@2g;no*2$~ zw5QkGZMYwzp;RCeLkb6N*%&n%BP%s?yn|n1c}d3LYRIg_wv6G53hZMP=#-U%l#gvh za({LttD~RytKdb`bl*4%7be6Q6$_#K2)cSgo?Bz+LrbwDG_V{!W)DMbaueohp513) zhdzs?g#p(>XTLB#|8SiQMgx~kB;+AGgVCxG3sy@rEygGn55vzno0`deU4pAzR91xV zq?D;|M2=JAK2lhcZf@N}+%dkAMb+a)De&av#g8VHUhEC~XJNnGqOIJHTx5Bz)5pHQ zK1wp}=ks7A<@#tLU-W_g1J($hT-7Q1TLJbq)bXiQ7&Ts^JE^SJVC z>dO4a$+1V}-T{Ns3{?vPtD~J*RnCYwGC3#hZ-8ZhSi}zZa@RAWI*dJ(``&ob_n+>2!?DO^PuY!;;o&h+1h?Phqc!UBB;@}sx}RTd1RY<{q0q$x9I%gm)lSfA&4 z-83*4_Ztzf$@{z;tjFf?Kjkaw7HP!51YL@h@f$esv>0XJR?+R0cnBQ`p_Jd&d#7{L zSKczJ@bNOhZ7D46X+|Ji=iE#YZct4cc*!|tb9r(PD(SJKqqLjQ7!10_#*xrVWz-sR zTQBJ8k_iVL77Tus1yQ5}uEg()JWR z5^}kzfT6~-1;0x4$S7=5AJ9}RyBP$guDYs>pnH;_Q)5t*>~T|T$gLFScS*-^c(C?m|9Wz22#J> zfUu@kPTl4^cVIJ(qX7$*PNmFJ^hd05X!eOq!EpQD=fH{g|nM<1XcY_@021{D`4v(^E6)2r-Trx7!DzAVa}gAw~r|<^qMSZLfY@Gu{Xx z2WktqHzm+xU@N7}vK;mQ)gZ6MRSjv~#ggy%NVp#~!xM4QxP-0VB@p%CAtnSCQ)T<26$ z_6Rp^i8NSvyS%i@mOrx`zudzmUVuc{o1f}+(R&bU+km?%OAcXA@GUk zF<4CgNH>CmuNk|A;pLQ*yTHy+m>bNtW<&v3DpwU~!bmz^L34~w<%B1R(#ZSK(yy!F zo~%V!%dujTbfPEXHt9mt^m|#0XkEQiITTOnX?*nu`%)&7{e)YqiLq(RyeO-Bzs1|z z$3>-fZapKD z7lLpVfct@qR`m&BUn2E^Em8v`I85-G9mZx-nlKb?j0BuxN0=JRN2xn6c4h9&Ih!?B zBCqM{^aDHXC_^&WdOxT&@YCH=BFS=`W_2cHYc+&?Or?Rio8xMTXo@mwTdsC8?gQ#eq_`(CkzzR4aaAz4UCz0B zZV9US;Vv{BGzq~a7`f7AO2GY%^}8KB`y>B;St5bUJ{s1Z9fbErVunXP;&JOb4hzS` zF-0A>*aoTYC1Lc496pv*@fB5Hp1)|YCYT!c(wmw|7MxT3R?Qup0_0g|d|$onA)xXO z0CU2>aa#$pP9apwy)1>fM`9{^?c=CH&d<&zN+ciOESjBDyvW-L8MixeCC8-D&jXU2+YWvUFWA&GDKlr(b9a>^?^q zcxwGqL>CiOqnFV6yDF=cpM6P`m?z6-`g@H)TuctW;ZeT#GAItg-nOX#@87{750p_sAT)P4)%VjVf;hlGXq%QXkhETq8x%dY!KFyRrKYz$2R4EPqVY53 z(`okfJy@KcN$XDcmU=yjkqmbIwjAGo+cF!T!~#FP0nOS++&K7RxAoVm9?E9S@is}q ztJ={RPkomrS#cnGe9b2L#P6sj zrLsQ%DD^D=nG+f=S?9ycIYU%CWFM5R=%7dmB+1*-9fL7|bm^s=Nkw`KQZ1fz-XDI- zgnNC{U=OI$P@qcR-jBFol}|^>6Z^e1djo6!D}Ba5+&0thEQpJ2oolBLB1Xo|p}zr1 zM?BRZb<(0*!06DWXuk#v4r}sKcZ1@{+p79Wg5b#_^%v+stghi)Kq_*+RWw;NMxXEVd_s&OvJxQ{G>gq73oKZeo1D0WMlCTnwaw;lvJiI2pG@TMfoD;g?@z0Y|{kD^cI< z4`IQN%oYOLM#)C}?bB|mgPsG{?nyzrv6&!O3Vl9rU28-dnH0xWpbT;v6q-5ZTFR7v zt|#K(#-<@nW!dG);=-kg=GNhx=I(0854)n{!Sb;V(=< z)sA4r+jQQM{!s&C16BPQpyMQT^ocJP>saP`m|d}!~?778FE>U$5BFdMBXe8sF*r?PtdK* zeLtLBN9{m%>bxU2WjOazq2niuYB@=Ah>qZ+nLjF{L*YhS*Xbm5(Ey{Zj7H4jNRR8_ z-|3F$f9Y+G_;`GFUUgw=!zR5i1gIIaRL}cs7_v0@d{XpDdRdIPq$0eIprukcQpyhm z3zO|Eyi7b0adP2~b9Whm@8*u4!bck$vom7rP&^7B*YTfpwG)WQbJ-3w?V&T+Iw-V{ zCj+tv9lQ;iJ?p49Uaq8d!<2Vp~fzKtI{U`9doqX3`{P1p zt8S}t>v|S{r(=vLJlV7ty~cGQnqY|Hr+Ztu!z^43T_!^N&^oPnIqr?v#y1O;#*qc} z7#GK4!fYS50?~mT3oguypy|_Z-Ugq)GdCAA+ds5qRTyhJambI#XyFOhVab`pYaegb4}AO5NV zGURcV-`{#E${oxI<*_c!fBS;&Vj9t+gYN$;5({Pu(kAe*BeEy9~yU8^gtv>i~ly2!u)IpfoXpXEs^Yw?u^7_J75M|BmpLs=!>cTPmW zhNXmLYUqEcbKX>K#clqHtCP9UV})aEoX-FmO=Y_Hrxb^hTQv(7UhxVoZ;y@HoU%l{`^WzJPmlK`hcmr z;zgLNgN~y5Alux1zEb&EG6H67)LL?BE&{Gr(|@UjSa=iq`ZJp=y!tK8i}Hh&R#11` zv`#Z~eI2b1uN_y<)$^`|CWM1|5IKuh@zZM^F8PW~Y&UPN8q4tmPo| z!4-_lZJV$ul`yFzy-X;+q^ia#|zOZxWD<#I}HLi3n$r`oZQI0en|nM7ba%Wa z)9xY&3$_2DM1r)r`gscCNyhD~cU#5FwEp?$QP?i%MYMf{2kCnTOlr-vbuLt1o#Z5v zAPO72*AZ@2ZYO7J&3LPuyZOtHTaj6z?wJLq1k^#O$odLSIWg8HB2dKuza3!M`Maqs zgjcGz#WtWhp#Uf9OPjKWX3av99z=F+yBI88tiFLhoZI(`s|>0U0(du`><%Y3kkE|U znd`x1gk4L={w^fk%3m+!dV>ucL2@(RlsKW~<_mHp= z(i_BE`k@E7t2ypeK5WEs(mXzh@B0*3S37iNyPU0dI`;Fluo92ZBS!(Vkw{s2nE}=< z-u4BO*2CO(AqVmV>!kPvCN$Dz68T_OQl3u}{~S0Hw<* znhnRB#|WmYSKq-m7CwZ^n7}zVS>(4JuTC0A2?$t0R1Ij{G#AmmstsxGcRssUXo|RO zv3}G!x8L=&DQ61liiBk%%sxgAni2=CbMv4`ORK1I$5li_^i0iKDXVppihjXY3dIV_ z`29zj?_p=W;Fx13(i>vl2O$ab<8oX#BcSe}D<%d-2w?r{gS zdsA2|BSCriuD>xwtI9c#|S>Y2P zFUxyEyU;XK`zB~}Y<6N1xR+rbBwwLVV<9cO>A!l%by}W^)c$}7%7&e9eTg_DBCyj-wLJ4gR_%i(};J=2S5O0UBY?x;}gr#~xsf(E`V=R%B!wg4aOw3ZkYJVsUK~Tq!%9 zw|lCwl7T>=rNO$LHQ2~(ANj%|S;2jMocI{^>23s0Z2vry6LaTxa0fN*gor!I&^>k( zbBd=~b&Hx{^wOhkqZ#~ZY#70nUI)!?{)8U;a5l!2WN)P;?9RQX_{@z81ZSGWsxRQz zPI_MS4`?Uk>#v|vD^UXK{`Sy41%AB;r5Z3@G>+(wVDz%jiO|bmZ5u+}79X1x^Np&S z2s^FBt*dt#L=`wCSe$QidNm$fVn_^T>du8v;A*|#@h^%MFe9?Xw*mhmyV&#i9cw81 zl&Zkfo!-H4PH1-yVJ1n@qzmzZLQ2Ypx3!hp)#>+WZ?!qii~u`ua-xKT_Jw1VWgzvi z3$IBb(NPa&_;&GesNCv$CLkm3mnl!MQ%!^Pmdxms*8F;XoUyIL&=OYLw2CkXe{$_a z#N|)B6Hfil;17rH$ge|kBJPxG*3UJ~$@-hc6NohB`~5_}>2ec2Mu_j}fw{xa)w7ZE z+{Ep$2W6_b6GF=r*Dj_FBTv1;NJbxNJ5k>zMUBCvtZwB8(kB{QZ5Co>i`tI!%1o~X zop{o;+gNa>q7DMerCMQ;6Oqeoo|ihZw<%D-HKASG-9^nx&nL7_9VrRU76XEGQ-PAJ zo0E{Lv+M?Hf=ACQ3>P(3H~xiuqLzA$(Z1X4fi11?j}ir;X;aYtaq6WRm-$V-RA+|^ zRB&au8xd^+*}wx-26QaP+DqmuLa=kH*^T&1EF@sgxv~!MF5>Ocg;JRQDEukamcu`) z5gt7`Ii?^2Tiv9WO~j+2tPNQTnfL$~8i>3dWRJ0ws?guO5zZ#3Az12E_i+98$mX-| zLr#Iyq)H9K@r)%wnITVuJpUQd{b305f6>foIG=MD+P~(r3*~_uVeg=_BrR&EW7Zrj#|1} zXVo%po`goA$3@qjUGxvJZjir(6dv~pX3vJc8f5P`L)ipQvXUr>6HapN^7I*|;k`?^ z4|&!mu~iGoZv*-DPmT8PJF)OQ_|oj%g@~gTk(OH0=Q%Sw*lI*|M@^65>rFBP#6;MMUIdWH8|JgvEH&Kz9B@Q`Ef9)G$!C8p3&X3Mh# zruN41X1w912Z(D1f)p-1FluT!(T`U#mXUa&BpovV$_BP_AriOZD-LubFM*0ONNK{V z6pB8->cZQB07DVLACqwsdh6)(tTv|py@+d7|8))&{>Nm75)+jYO5@NM!MRzI9w6Gt z8K({^(e1PHhvp?2enOCF03~t7Vqd*6`0?#1%gs7mBavHcrotQgX?I*llS8T>G>3RJ zh5k;dtvtLCpMFSYd<$+_%%90ES2r=XQyh* zMT4W&S<)TI(-lGZ4`P&r#7R$5`UfTwo?UBrekX~Y3U!M*>7v^6R5(J~ z&T?)^Ik;<{-59u|7)(z~%o&Vun=j+efn5j@Bi^W-vw`@DT#?HBtBygCs0DHwng?RO zm8HF3!2$(6B}wb1h(Rg=#P}e)@vE|h7$C8xJM5rA0XG-0*VR`NZ7}Z$y=tR{#)4nm zGZr^V1Kz7E5rjW}0pl7sE5-B_8q%olPsLOU8rR3IU?K-*fr7@B;f7zji->J1RX@BTORxAm5h&zH z>F;yEv@(|&+=v_H0_jR+8Ma+p6({o1_M-}1J5<_ia&UeiC+LT>iFlrBniAXG_4kSL zT9j7aT9iSO_C3l$HJAQCB-|q7R>7qHhPfg#exu9?tTQs zPjmc-h}jSW>1!j19$dfGc@W3s02vArf;3?)suF@YrHfjFv6_I*a8 zneqo=(#344NV{UJgUIMy=7`eKczCUXL4zbV6jvb&V;Io85KXUHETfdc8FL2dYIFrQO>bO^jewSITsh^PPDljcWf3154=OH>k#c6ExVgWKIQ3K$da^KbUVL%?%2{GbEDQdZm*i5r=GA%u zL2GwTnv6uk1;Y>7J^Yv*gHeHGim%Id;2VM!|L z!Z_sQ*2fd7+2oYh!IBlGw;*ViHZ19gcY zKtT5v#xoSYwWe7zKqn0q!ii%8&*gLQ0Uc3?ieusPQuX(02PPmHWtIyf-9QRkr*Tm8N8?xCNSeyFwRM0Twzr)1ad8zy{o)#XZO6nWY+I39V6oObXGossotjEGln|c zzp?f0U>-5$@=WTK;Z6A-9krX0skqB9!#x-VtSj5vAyi!LaBoz|S1mP4(1bN< zaL9fg@hu69(*qn|3ExTwDDct&QyBpQaJDKe}dpJi)6u^JLo_ z60_$Abx%}`?6t7ttjzex2?z0NTwB^AoEqIOVsD&-yG)djXmq+N@t_3{eO&EGp_;t@LXE}Y1EHT5n-TZwp$yTWUk&n-FWu zNGK0IC{PFC<&?8rhEc{ViIP*CqBlRR-WSA!W=w78#+Dpe?D`AVJMVqM_^>%uR6YMb zWb{{m_*q>#37IvAOY8n-@R{ZKT!oGHiXD$souUaxmXt>fOCc{RPp;A_uf~vABue0l=6}d|M5B?mKq5O z(t?pgs4N4~*GGt>unw4!6kwwVZtJ3QC&FF`TQ*O7T3**S;N;j@f^hve%(JfdgG|Xf zW8t5e=s|gTT3ilfp~)U3Z^4qT!3@;8JZj86=mO0*68MVa{aL=+Zmn^U6aFfPF=M9j z2BX%~Bm#m-?J-aY0g|0qk?C+y)wPHqKGEOJ^ZLoc`ce&STB@Th!{(@idG*dP|K9@E zW8NQ{xhukWw4x+AP@T&D6m*iwmA4TAv!JP{Y{oDw36iCzay^NsYQrb!zX7ir^XW&? z{b!E&`tg$5cG=!$m-K%OQ0)6FkT$cy-BW!fJrdi=cp~P;zEpSQd4S=RJMGWwr7t~{ z_uI}py5rh#xM{CElo^B~mj63GWfMJgW0&0{xd5AcQKq6E*`d-KK-@crD%e|^#R57$ z__wCRSs!#{QM%U2L>nXSmg#h{Z|(Tr+{n zascizzpHZa$OFkv41l;IK0J8(OUF<5H`y{Zb!qoDs8;E_l=S=W!kqXloCf>+M6$J#aLUo8Ue zF{Z~AKb#GEf}dck==_qrza0&V1ELroi5KFVq&h*Fuy~D5y6|tPYCJNi7d-_`TMfXw zv#7cpGiXC6t&C=DQMr_yjf@(u)z1}}9sAJh2@qGMTySax^+ne?gPCe0hcza_U+hS? ze1MIx+>Sp5vg=rVF1=jt*%2h^L{a zE$vZ{{68LP%Y~N`*mGu1J-|(eSa^Du;Uh9-Af0*x^4QBuYfNoEh2*BI3ga26ju1xe z9!v#`lIZahgm4B@rOJ0`nM#i<(~t%l`>^0?t6$1LD|Xk zvn_=nZOeq`U_2n4<^o3aF-h+;{;P6HvKQ@rU#t&a~!vb^(AGr9!drptu!4~U}g z6`?Hq9`^RXzop)^%q8MSh3=u4=RwOS?XaJPS7Z~ln&8Z|RM~eN+^w?B-5C#cx<~ul zPxty+iq>15w~FdGKN5ooWM4>@o#IDhEjDpQJ0Y|G6m8XBkXQ|{bTMpgJgC%#G40MG zsCwsYELpDB;-co=Tk$fADMl1Wxw{_@lBYY`>oQyKf}M5hy0QE!=H`=bX8mz1opjU# zr3pMYC{W~LV*d`G1^RpIsX{O7Yl|wZ{xkb!imlpNbp5Nof8nvIK{R+X*uemNHv{`M zuIN$9E;p`R?Obs=_dgKx?cCU+6AK>w!H$KwYo-viPJ>*v0GJ1r-|a`1n9;c1js^S9 zIt1Drq}H^JZHbd})j!GmBJz8gevWNob9ME94Ve)Q9(!4uE^Yjfb^-3`t zFxxuia9VY;!kJHWwof;=zv2#X3&Z_W9xkRp1y>Bp07V{G49uev+X2BjJ>DJwdL383 zk6MNy)NRLWB|{=-IyBaQkBr2Juf>{ixdI*t$3?p*OSMr;$V{76{}fqM)MhsjYfh<| z2*ZZ9-|nPljS*O>Jo@L_>#2FZ#*b@!6t6Kug!w3O zvi{!GUCBLV`#CU(IMo(Xh5$(ZUEe_TlpaxI|GOhNygq&rjtqWtRIv>KMo@1V;khI zGo~t~UxNSill4}=It1GIsdHkJgNg=xP0zUc34acm(*CXX0TL4LN-M{3#NTW*x?T_@ z_FwfF;%+zcSWkvd#(~9J9lRLEezws=M(PJ=D;Um0L(=vjhU04?%!G3`=NX!x!iaNR&1i_tS%`LZ2Ql77-P%QN z|BAFP$;DNMOpGL*K1d_7-F8@sjLGr^C=UQN&8Yx{_7KY~8}gX~njqMf=nH3qjKYMK z`QI(m36}n`s|1hsAh$6`%Ns0k#sb`cC>=NEuR3H75PQP! znk33tt2#zjqC`jxLOa?GSl~J=aKn}FDA^lCM7f&)ML@d0c?fzB&;>dL6^nrYrNh9h zS8lgTr3%<&M0WZe|yM2e%Ch#_zT<&)7KztD zRym#X`2u|nL!2%T7cQC*D60WK4dlTk3+CZwsp~*rqJ$4J+a}KVZBQ7)h|FY5)x&!{ z^YruE8DxqDpQ|QqA;)NIe9+B-0v@)s1HvH6Vwl$aX`4C{(9-u~N?N<=r(fI0ut0=UJAH6BwDUM=R|i$fn_vd_J+a=|QB=MmX0x@m`y1Cy=Itxz(r-0wk{WWA zXQ`a-+g?t&e=K4QU278U0VH-vC|bV5{*q=JAY3~E!L`ejVpTf-%(-3qqZW6s#)>00& zanA_yaSgF9Cnhb;0JDFNz-_zqlx9m+lvpgshM0~; z=;S(n(#7`&*TD!AnN7Ct!$rLHSh^Vl!Acmv^ns zMJ{J>r3nSEfXr6GvjL+C{bLr^3^_lI-!!22SdZXvUu))P$W0!`igfB1H_05xaXwDj z$ujz;gF;0RQIeXcP{9eP*yK3!?h$`U{jHu1(J7f)l_to)pTzs?D�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 diff --git a/dist/groupy-3.0.0.tar.gz b/dist/groupy-3.0.0.tar.gz deleted file mode 100644 index 9643379351405f5280cff32ec5597ee5fc102efd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 212614 zcmV)(K#RX0iwFn_vZ-eR|7UVdK0YUH(>&U|_R}`at4*72y8Y~K z;_lVKyHpWr_V+Ro0b-rY9NOi=z4>3><7FIT4e{~M$Kl}cFu zm!|qZ75%S-^?z}y`ltGz>Hqa7pO{@;Tdq$){wtMAl>Ez70U-S^Q~575mryEB<^RY( z=QT@hsg^qXjAjzu=q^c0LHX9qn%dEpB;bc{&A9J4n|y0#qu=SM=AKhPnw^!-N>3AQ zyWf^B8b({%HcaWf(bk&%wrWaeyJ~xn=;T{7D@I3~?WsGo?On_2k)<3!$ntkzNW_L3yai}Um41$jYv^vV_1!nbBF zXjl5Wsgc?BJzA1N{79)VhaR8PiM1q|t;@ONREZXP z_Pnvx*0(~Bjh@!+?OktwYv$ZuSMBIcTAL#mdH~p>$GZ*@(KFL&y+vCWJ@0XPpf?1@ z@LN+||Be1k{lBUIH}(If{-4nQ+o(T%q4xMj!Mbi8q5oH$my1FFucFLP{l7Fm7al)* z?)>A0PHTo}R=-#1?af5Z0%nG=N9bKkqYh3>>o)fqZM}P$2n%Ul%iL@9483cK3pnnJ z@TFz+8g1>W*5+@4keEYiWUpJ6cyW z!%egeW;p%NEb0UMpIp_oYyMK~59{AD^{u`|z3WC#HL2H4`z3|zZF1dT(lA*sq33%I z>Kt}yyHoo=mH(;zpMEB4|3ymt2<(5cCm`Q}0=1)1GEaRS8(5faaM??4PM}nA-oT z{h!+Z>E~;2|8e;Bkn%s&{?C;I_J6KaE>G=$8XvwDVg|N8%VzfxsEnZ|#B7L$Y=?>KFAi zQ+fy-vnA@yapz^07=5#;N$jix^`^MX*Ku7wqIcMN3+gmV)T@$uCaq#xw0Zj=27U3+ z%*^T2r=`^{9e{RwX6Br6t;-Hm@ILLvn?_4pnwj|?brPj!+vsW|+{OZZ{Joi(IxW~y zX>X>cS{j8w(z;i5)97}zt|j$}-rZqNX_I29vgY|0r}S*s-ucp?l_c<+s-=Ql{GMA< zZCd(OR@N&>U)q?k277u>(&496cn)BDj=5&fVRinQcn^!7L@}j>gHo zrEY2M?`0o)=$s*S4eOzYC>omN>&Po60=n#Gn0QnxRA<^F*o%-c$ zkeLKnoa{CgVPwv$-99bA#_ycb>@(WYVPg%mzUgSgd&)noyOiS1`Y4~i$CtWkl%M1^Lk$|>HQA^wOo{ack zIqd%_Q~xg&|8GGQ|C_6n7w4v4;5W_xvriTth5RSW|0S2?@-+X~bpAgb|LOdH`bk^= zqcikr%>SwQ2j~B#^7Q;iI-hOR=tu=g@AM2h2Xylp%!Hn^d4BjQ4+`nSZY@@`Xp&zsA{k~L@-n9nPGyNn$|!Si*|)m=&K6tJ@GwPSI%Cg1H3S~(ciYso7ub(%M{BdEIf);qFG$a_^%=g7 z(qxM_9_JR^b&Q7Epy*_Cc~~6);G}ChExyHkx+THGVo17vP;0_&vs~YjzS>h;5m<)VN6#dJH-qN?XHIuEX@e+J3MGXwa zFre5<5L+c-w1VXrK9oUuFWAxm^5M`W2&6YUOXxdmpRY1S@<1E_b=EywFJqhKJQyk9 zs^zR_ahYgITYDj;RzZgnRgQ)z{c1eN%b} zQIY<>l)2c+Y`RTc>``IHEwUZ;de+xPUXlyNTu!pVy#LDU6e#+LLb`wZ z4uy38_K)v<_M5w({o?NDKRx)>hl4-7wSVW$;*$*YAAWi7v%lQ^@-ten_+(H)m~3S* zd$E(3O1YupymeS{-ZJzy*E~}hp33l%$naq$bPG%9mOw(c;z;Px2uK%1t{ z#YnQWPLD;5`ldFO)2W=EI5}-%Ic*B$w0V4T+8iaP>ywky9_2(b71F7Yoc! z5-o@G!vglic5DH28X4s+Lb;Vgiy7s^UA0X|g^tfQ+k4Fhc`9mCQ9Eg(wh|V#6@jR& zBo(!lxT1D-lA?B1?HQ*2!c^L((snYX?P^%st_q~>YEo&t8rQJ($wWWwZyKGgJ~_g7M7n0`>@@}6+Leqhg%knBRclA@w!AbFp2GBZ+@a)S z$X_!JO5baYhCY0m3RHF4qCp><(k_#5$|^>ONLkWv?y`-{%Xz6raos2s3ORcmhEbo< z5NMqrUXBk;OM_$^T4*RJCAYj>6HIRh$HplNWT5rwDC8yGf|mUQZS(n>(G7`N?a;=| zZfM1AXgrf+H{%G*lJvMRs2jsX8GWnQ=j?z+zzsF$5@`rWxT(gP&CZq<=L&N&RSOQ) zq*+NR%Y}s!edZ8=Y7d1zcO`%5%x2_WeIDy zajn7?HWJCXJBEr_Y1A4fGg;JfAj{y8(Jki^ftI^Mtw>gu3yU(HT(}M7m^)f76)F|_ zbfK_Vs&HfDymecmczBKde^QZe`nQah!8ZQCd+XpAzdm^Bt^M1d4&M6byKnx1@A`l5 z-mP1I`{MP%Tet50@Jn_q;DcZ9-@e0k2fqK({_QvDle-`P_rV`N9=!VU;M2GN_Qk7r zfAiVkvtQl);IqMNZ@BFYUb#cRKx_0TYv=8MUmm>q5v{{s+m&DQP0KLe-h7T~G5R~J zPz;NOxw#6Sobg>!^eH9BVqt;4VJ^B@CDUd+16AYIy8H*2>z)Gj%!noUHamE$4NijYLSy|b;>3gJk^AeSs8 z*Gdd>k&fQh$Pi-L?QNW2ed_TI$QL(x`Vh+;)_KjYdC}PLz#Sd)V{XhaHb5SU=SCeB ziqVP|P1_+ja0WSg4P;A3>YsD2rKfHP z_cqiRmT5dPhpD_}pr;rtDmeUXXAP?dCOS+|{5mNY2)_YoE}DJMrO>FuDlEm1f6jQs z3%Wxt^-%CxaCDU-*u0F%US-|0V9Zv3$39~^nyG_nA`i*b66G*k4N!Td!>d!r(RI5a zDGD8K>d=X+M?~h_SrA1H2CyKlveB|^-w;sud>-htXHun#oMMin9b9H7V@cE0v~Gd& z-E{2B>gXZ$nKX zmoV(#v5APU+h8}WfR{AXn}J{9PW0-=IwmrwjQ~`fbd>ngj&==pf}W8&5hl)HFwzKg zWJ_;aQd_^QNjEZJm@e?aZGufRpw8rSte5c}>?34cxuW<5_ej}=_owgiGSpBPZiQ zQ?Mwjwuv|HIw?{Cx1MpYX}`ztPF{{+EBHZ(XboUVX<0 zb1pT&T=xyA)cV_m$zt^Env;SuCni>-*KnrncOswhr4=(`~AG$fnla zrq&PJ)H7~V&m6WXw6mT!C_G~_ZnG+TE?A1WCS0@Cmmo6q^t7P(~YXPn7o2&Rr zt^`W1M3(eTE_vC&+}Kwd`AAOjsBLM<(Zgom4~$@Q&Nu_(tjyb^65N$g^>0w9;=V(c zEIcy^X(?w)8JHmZr^W)-X$Ba_TX(O97gVa@mH29SVGe(;IL&(tZ&lF>93qFfAqZvK zDE49kd8K+K+?v=B1xVtH*#{Add{-@h3xgm_RuV5ga%ZMJ~{c3%j1`$?u6{bkf4n!?3b~Tr32zMx^=%~ zMVF5!QX~6o#_VmB5Jgu7F^Ya~i%|w7z}aIE5(Bn(fp15E;4qagLHL{ELG0)^uzX{i z{@duO)@~jJ7QLs{%ucUSy54~3ZlOu84vC9L$I{0LlZS6p3I(f!D6p*n)-LSJ&?NRa zTd=NMd8u%{O|H{F6#N`poPnS@QLkz(n_Qu`Ma1yhw|9WBHwDRNDLOMR5&VZEaIE*`lDGrQ%?zZN`8% zZlct(Y^PS!V!OKF>O65$TE=w+*wdQzz{!Pr4z=NDSETFM+E0VA9@`9o2+y1_J5r7w zyQ|$SC36UW|44;`>QOxx)E?#fRu)3yft6cbWPL(ULZ-<7Gv^;%k@x9C+JQ(95f)$K z=ML+sOvC_C&8J=GS*30JK_Adj$D=4RJ2|{TfY;-a<7%p`h>F2F1{9L;;2M9HjPeCKZJ9`s*mm9;p=-ZP{+Tum1oev zDdDHx1FGy$#|#{iD;Th+*`DU3+DAbsde1rp< zN!}=g8(}TmYpA2n>`^vo&}NwnooDTX|IB8kk!K#=q4&_B6(MFJh~@D^c?IFQzhG&n zf4j=M9hP%im-dXlbWQE@S(AO)vF3$zZm|mqh?nQsHqrt#6&T$06%gsT{e9D4M5EOV8?*7p@U$|MpKf$qqhz?fx%5yZ`aKsElunPbVev=_oRvjz{Rz zaY=nzKO#Nj9%U(`NwB|(_Be3kn z6gUtrbK~Z7T{j|yWInL&h^p8C_?E)l#WWU9%G+ydv>~owvy>PGe%EPR?@|StyHsA< z%n5f#Gc}__S*lv3N2*d;ybGA0YPZW>Ds2{Y<`uB}Va3G*DB!Amn_niEtXgrrYVvs1 zl+6&q@eyO!%G)7KWa}dq7#Wk82KBXg$Z@3kM@xKRPaw`#C!djb*7jm9Jya9A?5ATdVd{{>n* zSk^Lqv3klDrAe+7P}R$-dP6&Ejo>t^qWQ-NYc+!uU~%WVPArm*;yVPsuZZ;Ejhhcj zyHts7v6+Vh?kN{2e}lQvi#-v|GxyBbMP6F9*EA_TEsf<4V)~7GY9ssR>Ys2k*3!)& z(%9g1Tb>Evt|?_(|DF|9Z-;lWWNj_WY^%GW#nZMyh6=BwffXq*OWg}NJ4WB)QGHs( z8N25Vz3WGU+LjyK|8{A?7?VSlD{!{WV&=^Sh~vm;!*<6T+ii6RojO1>m8yTX;y@QZ zI>-i4*sfqh2PLYzZzt#jiXAk2aD25yTTU3Dy!m*$PFT?-8%_c}IZ(yx%q|_LznOqV zW^nS%7e%2=wb)`2*S-Z$>AFG-%AEpmbkqsb)ImX#Tcw4b4lrn4p;=hr{m+ zQ%yz;)7;C}FaA?5V1I4*n;|(PTC|O{uR}NS1SDpREjMuu@YuU;PM%p7Z0tZusn(x{ z%^kk*0Lyd6Cq{N3*nkyA?EPS|6%M(#dxS0h621+@=`Mb6Zh!*Db?RGZOyRh$s8T$ zA^V;nR*Qn-A@%2WWh?B}E-+d|S)|&bBx{=UK(Md}uVV^Sb4QD8+d(N%*liwdHDpM8 zG;otg5$2<+#DUU@1nL$G@@{YI*Tq^k6r&7AulT0Z@QCCJjYe0y&Q=rrlIP@|U?-kX zl!QVXbjp1G9XMw`56@XhVoO)p(`oLi-5qV11Sb^u7=U>WV^_NtaubDG!LzOc z)!@D?L}mE%Hz5=RBORu$d#bBDqT`@X!cJkUdm$sro!XGWl$uo8^2KpaeTaI@`IZgs)bT=VowxUG z@O={30Ow$IDU5ASdL$y$Vi#Ou&E1nSQkuu@G59oa>>1>zX(dRKDLlbe( zqOh|enQ$LLzYlX^3~w)KOey5L4P(`y!#0%pChxb|8=9lxi|LH{dvn(?d58Rw7n2p_ zW)gp3a;6O98!Q|ahJ zL0+HJXwcMCI{BW`@aPIs*tx`hO40}ka>mArk$i>Bq4?Nu;6QFDmDb3^z%h4Ty2rKc zjw!)!WYjHoc$lR!KKt0}+UiBA4p-@dfn(dP)uu)$4SA6ovkg#S8x=6&K%NHLL7IMI#Wdg5JhwUaW(`E;V_i%lKes4n7 z=DxXQFasx@tuNOv)E*zwoC(b_=fK<}XU=b|N{{IV+3TEH?Gk-w*OFFOrP|pGxTou6 zn^pQw&D`5K$4-)4X1_^?M{~*Qb+EO=%kp-gSVpaE8Q0m1a|U$=ONBYkVu`u|};-XD73JYO-Rf~&Z(l$#inU|p?E1i>SDrGdHt6fR<7gB_U6j8s_ zn=+wawW%7YG-^=oLV>Q)qwOUQoSL=CV zbR9&`8Agk;$v_UX!Tss3vAITA?5uP;u(^Qw8d-;1362IUhRa<!K&rKH)q|e`89ppTpj__;_+koqg(JeJxtM(2`UQp^|p8MAQ@q{t31TpE8H7 zoC71%a@sg=v`f@AHOv;}ODb=o9ytTosVO-39Sk?OcxTri+h7f9UEb@uX;>O%IF^)+ zX>O5%(b#TiTK4hl7Ij;wdHT+G|A|dpZD|()BW1Q#R%MyWV7wY(7|1t((_UR`b7*I$3%RX3GA%wyU9a||ll=-LG^QvX)*F9`@p^ue4I+Nw; zdsbH01Z=Rfx^c07;k#I;JCA51dbhc&vv;fA+U_dCfhs8x3H2_g`Npo+{*Iut=K-dF zP^oP)LyGQs8)N%*$VIUg>M%ve zC@IvbfUio_5j;PB8=6rqqY|R~E^tR`{4f~V0{hsZ6MwR7d zfE}U8D!UAcDp&iJ&5J@nP(Quqh)#Njjc3m^!YtkCU4aJH|FIVBB6Q5F!+ktjgy z6OwX{PWpQ~x7)lCgX_oj`o>5HiIx$$(Ox&SfKd7Lu5fF#{*M=Y520f_x+48!U+r4` zjC7p-Wmvh0LBE}mU~yetd)vRWuT36o0=^iCEx)o ziA{p|D^`Tn5mZKv+d|A^CM+S4RC8Gt_XS;D)UCGvF-#9G>fOtfkHr3NzpLBDp6=?L zZ*Vz~0l}2=stwsQx^|5MCTRFtn={L^I=Mz2LfZdRdY9kttU5Aj&(^ulp~@9c)#=Lw zVH_KQIJ2KUgX^2`p4~Wm;cUS$cckyuA9LQ{3?AK_%}5VPPs! z>y)N~j`&Kezq4RpnZg;?oG*KLczJc2H+-7^dz$}yn*V$H`6lH5=GOvGBK!A<{NJT= zaZV`*^M4mB@-+W<8lNHgxWl=?BNKX$O5Ghy(9M$kuw6({{we-zW@e_*p!Jw{DP4N7 z1|0ZcUfSf>udtlnGi>dAS0#w-7V@yvc38_H?#NP)!!bx^XcHt zj|QK-~Ziv@YY!yx3jW&X;Ph*iG2`&WdUAu zp4oXZ+zkS5;iZ{Cnl_!)TP0_0fP*PJ?QVHyOZHhgU{Z-`~FLS@7@3Tc<{x~2S57Z-Opdzzy0ap z!{6V1^Va_D9|sEj?Tgn3fBxgWzy56S!LRmjzqo(<-=K;^E*mO-^;Q;bWO2c8^4r?E zlb_YC-PP9htez{ZlGW}7mNb`T$^(pa!ZuH`-C-QB07gra)$g^n;OJtoC?){}V7w`M zgSTIYl7MIAcr)%}o%5|Z951|chUd1WxNPioIvX1s7kKt**uHX_?R;a1>4fyR*!SlH z+~GFu;|^BR8#S-Cen=KMEp1D5#z}>WGfSgblsU96`D!aAhYDBvnzB%8TZ zdw8Yv@Tm)%_MEcACk6H<9eZduD9C(1e4f43NR~I4^RWoarR`SDjt0l&kT{$OpQbDnTnjcoTq|KD6@!>Lw>{YRs?@ zCT=2$N*CJ20JPO)(v>Q(a#6quV@Vo=_~CO(x{NH#Lhug>eQLy4LYz{q@gEAc#DhQo z4|P=TeRk*IomXUK@YbIOAKe*z@G6P9_ka0o4;5*FLp#$k) zPQ(bsCqdqH_osh3c=MCMq?f*>ie>-K%lF^?uY*rNKKSvE>06i|-TUjWaC!Fd&mZ0W z>=*a``oZAM&jZKEefYbF2SYp%C`cKBPv}C=5mU?`nkgvBT)v<)tFIkv93@)}-g;;7 z%AFxRLCq735d2>{wf?a^4}ay$gP;Fl@ap@6PrkhS!R^6YZ=}X63qdvKUdn?n-eH0vh|!8hr^;Er^w}%FbZRi0{HW# z^haSnjKb-$2EjKykyFHAL4?6!5lC@xXIEFv*Dedzcw$fbcoITTflkAmZY9Wx4{ z^^^D*aEI)e!4EE{#D~6k0-zTd-1;FLhGW1WwBk}v7IjsQ1L#G8w^=`gubDmy<)T1& zPEvb%KT`2~%CbO{PEwVojgJyms|h8ebdu01h16_4nfp{eN!SF;cJi@NjvIqHkux|s z%aJxZ;kk3Yo@_JBh%rew99o_i;gfj2abnO}2u+@Z;qbv*?;QN`RTl7OLH{>cI2>&S zy#LM)14C6?A! zlI!NRR2K;576d#!7E7lI^kSH$$KvP|VXlPd#%ti8z~cOeuWeT<+1g5VE%(1Ket)|% zpRLia?xMT1sQ)hYU+(?x`v?E|gEe*z;Lh)8-BKd=#m8xmSC<9>!jtLaA%Kr-(aB`$5HRw)U99tY+$MJ>GcyfzNVY4S$ENK?iLPG(|Zz?dTyc*6$<){;()s~(Nh zfjMZOu93@*Vr^I9!wH08vg5{_c8)#~HBL&uFy`0U?WeXJPFxA{(!S-!<2705&8JOSW% z)|xutK{K8JZXAnA8}5bhhOoGh`#C>#zypSxLOteWAa%$C7Q4PykGoUQT|`!9(LGqghg# z7zJm~^|*ncATrJXr5qJnm=7Zm_ms*o6!3VYcz$JJL4=MnQFPK|uf-5LR9s5A!z1L= z2PJn8K)R!~SPY@ZHkz$W7(Ji5OA|d=Gt~tmw$aVsIHQHm^mENPyW=UJI3jDe>0W7WVik zDeh*06NHi?LTTa{rF2qS4yN1VJU4NcOAVWVr?8d)8KDX|R%GUc+Iq}g8?nbqNm1cd zA}~Bs1k+R&^I_>4cV|T${L(;4urHvUd5CplArGn7d%KA6Qc!IZ<~ zOuUGc<%6F~iv>b?0uzADX`42@p<`w3QquJ<8|1Vh4{uOkPOcc1Qvo%!D}6bcT1W@V zV4!|EnNmmx$k3Ab@;EACnek~l00#}&a$dXa!lr+DU!;bm$rmF)t?tG!=@5 z=Y*?C;ap7v&hX6cF*7NkkC|ye8n9ohUtmnlJ}!@%a@HzfP7=-{80Z zHu&uu2fzHm{+*ZEg;Jk?LOt!l%WoX~%Zmpe(eMB2QX*jJ*H^N=U4!&?^=?dlT~BQf ztsKk^wmt@|#dPwz$ibXo>r}-hgtkt}N&((J@ih3#P@yC&$I zoIW~>Q6n=ZHbqwh90NAN>|%6q$~HQOKnCJHt|~hYyyW00O$aTXul4xQ3a0|2m?f}0 znkutTt(2&oh`b1+F*jmm#hHMhoeK2J*4YB`umtfnN$5Ujv8cQU|?Le?BHISueD zA@G&Mg3nkh71)Ctv6KY)Z@5T<6xblA0dY9Oo^9?m+j=vXkmhLs8;Yzag;h=~Ik_B4 z9hMZ-lo()M4&O~b23ftE?b2d~P;M{^`1o+AMeS1P#`MH|kuJbx!3FinHH3GpIUT4& z$utvzY##?r3D$tUTT2Qm^OsTr6^27c!^+Bfi}24tjD}{aYMJk zO_d#j*{?SA_|O*9hgJ>|SBVX7S{fmkl8OQ;C*y!LfGh=pOg8LkfEch+Jn391w&?S8 zSL}En{D8b`slXkaFIE$!tnhY`rvhv+KuW!{>e_6QxRN&yNeSe60m#%SB!k%w*rWyX zf{4s3iODRd;PE;jF9zATo{){x^AVI_rX}Es#2k?txWRSEn6zC#7TPLb9et)0Lov~{ zG0r5(X+c{KP9CZoNpDcCdRvC%RA3fe%Nz3|kj+z%{@v`Qn*X}fwRDz~m2h&Rm{P~V zPSej-f@y}-m=?L4+Jd(dOdB-2rpD6>YAVnLjMwawv4DFr4cLOYf#N5~awfDv@kvWv zh0_HkyujKIc`ZfAm9f{-`Eq}7F39wDLKK5v zhz0yZsQ?+uDx}Z}P%QYCU7eOnDj_A5N@zJbq({5nLnOm&X(k7lG>XBw8r*$QOMDe` z@Em$tcK|RH6j3CtHVy{v*@R=evVq}K3a&H$@XP(%Z_s}ae*ELXZ{Oel@;!E$@0%YD zK7D)e=H~}*zV^2-K9UBXd^z~xTlVtZUw?G(ci%@_zZgaAKQYC6)|c=sL=N$r*%b5v zBTzXhfr6w+aKpz)Qg}S`&}Qy<8E0;6y4Yf$oGxOCInh)Kc^O^mA9ZGHD?R%6vUEA$ z$#?U;YB4Y8sB~G+`EDXa(24J~@d)p(Jq#Wwb#Kae zgf`yx5FVVZ5@T};CJ?FP63X+tLg&QPe~@amz_PDAIx{g&BoqiX@g|hfS%Zf(kz|XT z^zGp#lrbn3iXD_{^|-ez1;j2o8+eMB4>6n+Ax?b!`PYdfC+E|du`%RASutw$I^f2> z#GSdxClR%>VBGM)O?u?&I4xMpxCWyg_1XIDA@7k2BXlfht{HYAS0z~vy>zp*F@Qr? z9j+uyDv~n13qlpwrJ6vJ4&gaP9&cl>@=QRKV?j5#=*{dn*9-JpeI?n|Bl6~He7CiO z+Hv3&jJniUsLR!*dZ3X_97)dTCHbz zAF#qZ$wMAav0j?3kE1dI(-*;X{v=`_@C;WrDC)DZV>cUqE|*Urf+YchlVR|UuZB)5 zg|5f+D6n=I{L!@aG@j4~h!I^b4dmyL2kPelsH(4fw7|4L?c)@o@Z)t)$N` zkFzHLO=YUVsm*d4$dts$JZ4TBo1?!ri=k5%qq90`Y*y1iW=@PutvzXMj{f>BhE62{ zowZ4$6KOfpz;ZqUOYT^WqZs`zLma%(p%3;eH$si3L6wCFs;tzHV(^_UIVtCjXNWFF z4*$x8F+Euma&v>q;waY=SpH^Rwjl_ilJk{O$M%MoB%S%XbV%oAg$QJ4CoFNL<6z50FZ=M_rlB$gMs6ZI18kX0DpT(O-s<5+P@>#pFmOE#+Mzl~NR=9toQsV?ex8 zjSB9WG1=`EJf>I5xFu6&fbac=PnIBf|mwT0+$Ak;*Nn+oMVOQ zV<_-rmP_M^pNrmdj{cWM?Af^wB070-mM)hlT)8!gO80=$oq8G*XxSm)&ovbCeMX_D3&mO`a zr$13&6k6o9`Zy{$?Zufzp@H6*9Y>~9ToGI>i~ZC3I6{4VA;3kU@vhH~<8!1*eWC5H z*Aq{|PSV2b;(UaPO^nmYS-VBmxAt%U`0o2J)$aZ7 z`v?E|gLCDBcW(_o`1StnJA>EWxc|$K4?cX2%qvda_}5aFQ;yEaqRUlSa3%qorE$?r zFPb9<6aChbO}ZSQQ=N0k!PLHwtR)HfIoId1D}QIx=Bknya%DWo(`1x@#XozYe*VJh z+7m=3Nmr+{mrkj$DL&$veFgf+uW6t?7m0T1FlZxW5>p@zv@4NlmnVdFVhW^z_Ixyx zA8x!S!un}YU||Ra%10>ZCx-_Xqa~iqr6co0UZ+5TV18e}6vW~JbYSX`0&!yaBAClJ zK$W>vsd937MH!|qj*u!NC~(YF3AlOd-@UMUZguVPn66UShJQ@|AZ1+35xADd!1aW? zMZvtnF0SRn;d&D7U@*(D$HQZd>{rDB3g#+CXqP81yeDV07b4M~J5rr>%!+z3675P_ zsybLH1+;^ikv*M#SWR}KUASO^WQ6wIks7PxW%6LQWUR?2#^&Qod>Rxeh3*35xBKK0 zuHs(UWOw4Szi_ATNy&RDE9K!+HjlqgkGl3(rvJ}!^d4fP80)+2{=iqa9LLn~m?bTI zR%va8-}thYFmS->$+pti`yN7<$WmEaO9iEX8<<`5cD%>TwH%h36eI4+yy)g`cC1Rt zDTlnl8G24}SfpzkTug z{_QvJe)P%x@Bj4R#W(l={OaI?U+v%diT^;|8r~h1ge)6AvV<-FJ<8KSAWVL&N*3RZO!^manAAfJW$6&_&jP#JC=W=dAM1^lpRvN7E#juo%zbOOl zPj@ulXdd(6mBI<$?ZNw+4V~{64>nYIMsY$0qZ~>`{dFA+MEnyv7IUJp=;bcY*8PK$ zzx+t?;oeCY7&+#^uxigIua-nLf_e-OGIdH^RT+QG^I{A4JmQ6;=Z8uZKWt3-VLX1= z_)7D`d=x*dty~vy#P$4?Ct~nK*rWQ2vBg68Ug~<#ik}SiFcCW*xbO^Zt3IJ-eKFGC zwQq>~`Z~P$K;+374LNkZ)aB45@02}3$W;S8kh)ES@V$!V&g2kjD?kRC3`xrZBx$5yp9_HT;d@oN63gp=mFF(n>&yWTnz{$oYgXheJq}wPNR7>6p9XrDba-|LRbWF zkn(waW`_2T7%zGiJZf;)bx-hlFH1(_ca zdP+#J744|>@{9PCJns*-r@(PDu9obcOS+CQ+slU_g>|ytDs3-e!HdEX_U>2~(t!+X zM_9eHVu*NAbn2{<8Y)U4Cbn(o<02-3(Ag;fJR~v{EQdiAwyGW%5u_2d&eHUSp%%+* z8$UbZ+8W}>AOBQ4lI0fG+_f^alEOBGU0@{r9OZA8fAt7FboXir);CMP`ve{xR84c^ zruM-w*!+!CPTD}8z;dS5=3x!w@JLj@At*6y5UYu{p3NFKN%UhVeNj*DxK=zPI6KoMNxmI68c1Ik*&25uC*kk@01sg$3|n9oj&?{}TUz*S>xoy= z6fih;S9=WO_(y#zJ~9N?E~Qy)vx=(@{_9a=GB#Y^m%r;U@76h#7&Mva7#7Aq#egkI zu`H2P5Nq$vE7cnsB?a$r0O&}6j|>2%=I)fgDklsJs>Q2 z#rFIOUau|qtfp1A$1+U}$wg&~&kDg9YXfF?5zbA4vUlizX7X;+W%o@(pyWL*%~8P& z&tfEV&qt7g?R?2SPW*%0Nf>#OAP(9YL~$=}=fu-9c|Q^)50P$Mp9+N)A!nMfF!3xH zC<|%cXJFJPZU^DcV1+aL*3aDal{wyB?3c@h(x0Mkj}jdxPRh+ioSA5E!xTrWx3;2* zvg;`mp&}tjADPL49AzlL#b(6w$4nP1Zmu+; z<0yEKyByh|u0>?q;>%I|yI3YMw?>W#0$C#p ze!~r2h-2Y-rh_^ZR^WC*5XKt7$^+KY;V}hVwMN34R6}QE`F*Pxd9sq?S2v$`1W5^YNl5$38rLEHmt0~ZPK-r zWkXPkMfv8fm08J=fq^AzE3k#m|@_4=(vsIFiPKWx~}a*ZhAB-0D42Cuje>IT@x zU)Yu6GBycmF)#m1bX+PDEgMD=NSS2n~)qaP&Jy+#ik=}Pkc1`78q27DpdFk+N zp}#!IcH7TT5fft=f&@hB#v;Nn`tu9Z4?}>t5$0|Es*d#6l|_Jo^5^HLr+@$h?ad*; zNN}_3hRO@`*QFrD5Qhjx^3M(YcT@h?x((z^!N0nr5W$T8xskdQ#2DY^ZAQx$W1bjh zudFbA0wRn9L@+x@KaRg1Tra%}+7oApDPxFkiXiF4s-!|SE54t>iMVNMmHA1B5*M1;A62zL9I3$V9_AmUAnk6De6o|*sy4FLuk zA{Z2;AIM+t+og)~)E;VD7pki&L{CkGfrbbZ0TKKQq@T!NPp+bRKTiZ1^F;E$qO}EK zrurXzYG`LvYTA582r!~1qyGoORdq(7@*rnZ8UG9H#lGi5`Ujudw~z7feYY9kF&#q5 z{*Rgdt&3XQ_51sJ{jKj#--~pLIE^5alK8)XF7zR>!`IZ`u_*GtpaUVd8Vyk@b}}LE zzW|;1e&PF?fu2bIR}@k#szBYcqZTmz4ZagZ*?0IMYVsGFCNpkS$cA739*C1{{QrP3 z+b&4^zlT`homA{UfGESp{~v&~sjwVgGxO^!-C#-}(OkGPRaT zm!1^0;eP<)pK*bIKsdcI8spzXIgp_~_8&kLnHuyDK-C+kHvD^3Ko}Sk{sVqtFhOJf zJ*fKrl~)ym!`S*gc;bfZZQ9HN5a7`TST|Q$FY#K`g?F>riEwg~*W}V(p{%ehUgYE* zFVs6_Z#Lt5Y5*_lGZVZI+V&LD7cu${d;^gn72{vct)}*TEllO;blJZepvoW9W&hCt zqytq_Oo#u~h?i2Fw}4b8z%3WYLb%ImR3{XDG`Io7i9-w@flgMsr1gBma%235uj%wNnRH>Y^0M(%G zr%W|%4ysV&7B8KQiYy8vqH1gNg%tBjuWFH={; zR~aoQ5!BQOP+bkhpaY_kR-im$`jn`l?LV2r8ikYa?{PaZKtAHv#V|gB7-IxfjKkqD zbwmLb!~dH(Z{f!~4K*ALQ%4wJDH|fR%<#9v7>A*p7`EXXfc_dbOn_}K7Wq#D!!^N{ z`B)8OVKEH_!B%3A1~C55IsfNMP<>(lHHQDM;jKh}Fbsczsm1Xt8^)qy{%>X$_0f7DQ$$YQ!HV?z#e}zJcMFlnd1*j&+r~H?B9tu>K>uX6tfc>355j2DHza9Qh zBmUh?$f>Y=y&%I}vPP;&2RSDLY!~bKznX|^Qb5k}z8fUEY>>^(<$sCj9FR6H zm^;FbJ*7L|HwkY;u9h8G9_eSRM}Fwur(!K5{^60}UQpybAN*b55?1J(jzwkBM0=*} zXOqXIA>~1rX_zwm=VhT7Z!0#`NPNlp;jqe4%BH!HCW>gKvn&66un@2p_hj0yzuy8gzmgYL?wfqwv9Rmhd>dtxJrbDKc9LFSSkOaNH?vE`ANq|bxxk^} z#NUxs7#%bh=2|PPL1j7Wgr{ko`wKMil@-@1WUgKlj*<@CnsOhTi?ScAIgT;oxl2*U z%m;v~MWk+;+1+DB69FPQTL3rv53KfRjXvf+$+&Fp^Md-!n1wYm%HA?sj zeKq8%G#Ssu2($`&rxwkTXPBknNmHPekTON8-4EIOxH?3bp56=m4X=l>fUPg%>p_i| z?;mkE9dF6)-JQT{1y|mmGo4f3Os)^`E?Yik*E5;D;P~+Q_-lk7ZenetPZ!#gb99RM zxlf=Qvl$?TDmJg`buKXZeU}#h#ylVPDB5hSTT301nxmItxM5`}+76wWsn9aUtx5%Z zb5S{S=M1&^Y45;9_wfC{U1`5v{jN@E!G#PRGRC(Xcrh9&bNLV4&yrfHFROb+3pN{c zzt_z>b1+^y=8=W*ne+jgrmr+FCuV%elUagy2}uK#0;ktRCBw3?&M~x#GiB``nl|xg z_wb;+LtxkyBy!dAET<=H$D0EUwQuJuUaRAS+8$*rl3k^!5-Z^k^VSliv+N7<&6Gf^ zdem4Dj(_Io(F_cgwXYsuA4||{T;&fC4QKrqtq>1y44ES7m;j+o(2G}x6e+nBrD};G z)bfj+>U)c1FLf)q^Qiv3=f9mcLcvsm%^6(S`ee<7Z7~=;Eq~1RF?f&yzUh{6D8>Dy zpOAZ6CzvW_VHZW!5_5S2jed%r^fRJPo5Z48q_KR}i1r8#+p{q-`L%(aqrozA()Kh{ zuY?aY5k!PL0M0;`%w~DDa&#&Y>{Ul?i5Xk@&&7r%rtf_%4NW0B$Zcca{Q(kJU z5k7JvV_;U>CzQ~0HQcVvf#h(u=&H>$OJc2U_B1f(eokdwoBu-YkEKyuVaQ4X?KPPv zYu{TM^6Z`>+5K}`slyn5BPcFpcTTHtracn!tr6M28pCw2`Zx1Cl3e*(@xW1I^+8x; zV|H}`<|_^pvd${r>WNR+A3HNED;RT9V&l)RjJl@G3|;s9aaj< zd>J`JhY=djPDjn5vu^1=W1Rba$edC~7T4qs5bJ|bQPwcq2C`n>zZfyhUAz_2PRtaoyN-hz*fZNO+sX_zd6qQ8jAY40@F1~g5IOVM zoKFJF<7#B$#;f0TQ^RUv(*jxJ$zqi>2^s){`s|$-2N(6@c}C4+>u>fjb4l`WniDiT z2ywGY`^~*$!;9E`5EE(WN^L zaSeUa{A~kBtLP_RQ0MiOIO5|5(j6%9f!O3YjXX&DjS?yg&N>0I=}pPW+r^s?PA5X^ ztO1wy6uMza=0Lb~zlaZ?Ow7`eD$v-2)9nwV7Yo}8GMn8>jIMu$R)KlLmFUG-giZcI z}#63H?lg->dQ4oZ}))3&49#Eco z>*{y$O&6={U4Cz|4g6&Xi|d)p_s!ts6Pyly(|h5oMZV$039PIi?r?eK+P``a#LOgB8wSAv&9H&s$I3?`Ip? zAKurG>EJfspF}UBdY^c?o|$p#xALT?4^mV0e6z5Cj#(DY8E2}+Q%$C{Rkb|y#ng)g zz4weaAKS)#$!rI@%ruSKT$0&e=OtU3{6(%eNGGD($Xh4XHB_2S0he&0c8D~KM>ul0)6y z5QeIMPV-%HeX?CC6DTPSI=iIAPhQYHNT53*5?WAvjU*ZmAWlxpsVBi8L?Q^?VGv4U zwRcANdr?nl7E6PqvbtMTT?aC*AzF;YM4AaF^Y^&l81u2KEWlr?21uZw>Z{7xldB$4 zMH9Q7+>m`}vXX!425vOnN3%MtdK=ebPcO#; z7#iiu%=>emS-uu$bd8Wj$ap9LAeJ2Sz;;-n4ah}FM=$6WX5LP07w}-bmtz(55Qk4; zyR)U|CQxbFsABCXl_V$nrE&?$4{7x6+_-=!92*Q(j^-8ORA(Kh8&c-h-o=Nbr4$mM zclr*&Ev`Ccs*+e3n@db_PcYv&EZ%)It$1E$2-D6%_BhDU6J6Pba}7auJ>g7;PKCCa zx6jDaJg_$)&qaeA;XOln{V!QuONW!0d$~ZF`7M39QrE2&ht`teLhyn!GlZO}yzYiK z@UB$+UvV6gzGb(bW^te0bvY>EOz@JtTe0w_O)K)bsuWp%_$DQ3z~l>zDVUj|?eApJO$BxJOymXs^o>rb$0U~Z!`F_ItlhOoCa-9pH zpSZ9kUm(K`fz_`pu3I*-D%7*DBAaI;XkCbZ=~{}7S?(~j-@QB2I)AR>Ss_ngEI8M1 zDbrNWtSe36POO0=Hxdq3^%O^>k@B-pjwMC97wOGVPFDSMJ4md9vLrJ@l~ZEYLrT5PixFx;ce zzO0P2*<#tI=9!D_s;;s8mxQ$SgQKm zvE#AgT9tIU=~8a`W0r;o8H&&jWCtR@>hYEc%5qB4oP2xmrXexE=kQIulp+gq=ipo6 zL)he_FT#~CfSI}0!h94Lv`{L{^=8!%GpkCo)CMx8ga)s!5NnD0Yv@&#VwnfTk2R(e z+D#-Jw6|A?wv?@nUnj92Jx=Um*tnf*mIYw{IaGb9p$IM(!Y>XkmsP45}nbO=VpdhT)R@&^BJm7Jw>wAE?0>`~^YZmi4DFrkXGGdDbQFP!c zj2C*gXR5dkLyAfdlE!3L0_pf7Uw{_$TWaRp(u+BBqZKhwcUE@$HaU%oyAA~}*5>M0 zmg9P(#pUKY!8pXQZ75c{M)BULx4y@6)t@kY8kv(+l9cYMh^B8QxQyt<6`7Y7{lKWi@`(1r9}BZ}LpbAoWeif6;K0<5j1l0`0HRHz{_3jdvfcriLC zZ)!8)<*Z@={~}F(x^sZu#O5>rt#K zSCySBE>o-BvYMY^>{#}$t1isN=+9ALW~~x7(^ckFdCHP#W!E=hiMD(w2J75s|2Aqj zt;&7{a+73S4QUzDfCZfonzylmFtU0;e=w_$>fkNXm)y2(C~z(Xz$ zKfa!5cOYUE1ZycPi&(t?D`g+kQ(x@V>Z(kOR^{ zGT1|$z__L{F(7+lzt85@uK*EK#7H(If5~XPue>P%rB|rTvy{+pWsK`p( zIDIHiSsfF(#^DIJ6?@h}iGb?EN-!B8OkcHLqq}Ea|O`-tu>i zG@K~NDah*T#Y-(+PyWQEYnY$=U@U=Z4Ycw3+T^=)< z-hxOn%s6e4 zO4RG#x}mY$Lnx|Kryq6pT_YbwnKG-wF#UvJkS^z3%g)2Vp*|ba{T57f{LI+l-faZ< zywFdwGzSdnmw`QA?JKysX-e((1%zQRY#U+n6>W_e{>93s)z0xBW<>7pHWvW|N~8sb zVAY?mpB~*C-5HY5E-wAip;S6SctGSA<8(KDPlcg24IN#AxI=|%M+nV+Yr#aii$LF zlx!RXy~6X*D(?J{3y-%S+EjuZw%9P+c|3}Mr))Be{&FMB6IBYG_k$# zmDIqRbVO8}xf)^xr3e#tVah>t$;q@taXO+TRxSy=w)M1MZ#GPeEigp;cJWz&kIjZb z@XU#QRJWG&4@m{sp(z3W^&VDl8<;=|;psTfEqj_p_-l+tG3FuVU_o?0OrCk+yd;^W zTRRhz2i$J#KxmWykBX4{^`8mwLaoF!gs4{#xcSXqxNor)!Vtd@Hh6LoS#&qEg+=6q zii9`E2r9)N(8O;|A7sKQN!DO-!Uc0B{x>brL7f!9Z*1h{ZMyCDi_R}EY31?E19xu$ zo}`*jdJ+{0rT5zOS6I)_O6M>G(TQ?oK@$z2lYMIA!+=1!qNz5V#WAWcwkTGv=zctw zZyPm&;ZYY&ZF^a1eAYQkY}xCMg4;VGUiYaZ;|6e?z<3TsT*~D98#vDw4jSdpgQ}fD z(pC%%==MRn-crA&q{@&pE+W`6)`-r_572fcM!1ZCm-su}ngmmCpBp8kE6Hr=f*v35 zHV`UG0C-X$jh$FgeX1hJ`24bPN_L%$lci_z3#pjoU?jck>L?UDXq+E2+ZRBm(w$j2 zT`wBGu-S%|E~dV9P8FghWHK#{r_KkZsw8flp}<0X$iyyi86+L(G`gQT zdFRT*RTLKwriu~62GhkNn-cDNmfe3Vvd~^*mm!17-3&HpKY*RrNm(0WD;$(c?^Q>l z?sYUBc+}g_!i_vRK6%SnU!OXiv8-dydFR9DwZ83R{fAiQ`Kd!3KOd zP%VRXOusa8sK5L^?0G8+bjH~gO1de`*U%w3p>RTPybvA}8Q%;!UbbeAzoD0(N4j#y zg#RR$-V+fd1v8aKAOQmCwSf3?ckj?PR1~fj8OF`TywURFUcJiLga;weK{J_%*r6KG zYA#nM1$)oPZ<#XiNd*M=fv+x@sW7i&TG!Cp)KVt&;$$sX+{cny;b(t21AVa54{1z> z+~_-|I84HUuAq2PeN)o4S2~DzG0B()nLA#|DWAVu-r8Hc9v2xDc_wp3J%_L}STcq}pV+LriQ8h)fg z|9j?>`ix0A@*KStAfoOwk9n^PfxVy|NX%Q;n{XNl&?bQrjlhoy)}A1%-@fTt1m; zFo{9E)K}$f^5wg-mDb92FuB!oIhx&d)z5YXM8(HpEhu54di$C$wcPhaG0%iqbj&u_A5kZJf<~cgk_j{^$7l}3z0mz72`2d zFj3mvMpW3y#*%x0A$pOvYw%2#O>mO57;N7ny-zAAHi@oJnEs4@bp4f8)C<~~g zp*~GG97BVc<7Q30@%-({5N|dFer`Wk7MLSsQq+WSRcr;MWFi`i;rs>gOe^hxnjXM_=IkzI$RMtEEA-00wS|Irynm#`HQU$7yWhmyF zUn0tQi-?nk&Lr;r>a&v^7x&zD zV)Op@!(1-0%jd|b1s%XT9pim#>6?P zdH7J#{)<^I8_IJ3l0MjrP6zk-Kz@HRejT`sTmX=Zl7jM^sow!i2EqJ{B9{km@ThfI za6ywV9wt8WUAAfj9yhZk&e56AyS&@r%Rd$nS1pCQ&bL)1+6vS!R$!X)-hcYiU(8|z z{WNF;-d*~-#z|}|lD)jz-5kIOod$6(>=af^1R>9;1!Ih%t8f`P(n(J+~2tn>BE zxtTOaokJP9aA`ke5+f^*8p%%A*;A&;SDm|I`3!@t(nA}KoQK~>*nxJe2c2T1uIpjA zdRSix0v*5v9OwI7wsl5t(+hX>&77+pUFT|;Y-_7FGqu}2kg7K(jN!a*&H}zZUph7D z;I%(oI8*{Pi4k<)*U+oC+8>HPrg$MWE1+#Q`rdTBMae~;K5yr~iQ6w*=svL{uq#~N zBitKUdui(A!@E8*3n0kk7aR(Bu&6-N$4SfI#hA`vIT1qk-i1JeB_uWKmJ7q3%&uTz z2gy5^c@r60!>yp`POST<8ecrc zO|=Ho(pl*vhKx-0@t@;8;hD^w5{=JuIn=5CM|(bakK5%%j!orFPY??YOh*mn>fig@ z==`R=FEyWSn^kc4F%B|E7U%tm+EQs|73RI?mZBCbV?;b0JXkt@50!sGJ7;0;5@WT$ zm8Us1Mf9-3eHt@0`(mPDETZ;G=6GN7;Mtwi_!?xq9S4TS*Jwc>elajfV zbgYgOwsgbvN(|Q-ZV#%`t98PL>`L6ZzLhwK1UtLdn!YZxI+87*={x|(RNiW*!8A2Z zGanz*>ZI;nqwRd(?DIm?SeJLn;dm5(SugC-=0<>;6Z^7XnhwOOPSznwAB^GHJQ&s3 z(uz&DU)6U=c*`Dowh9&S_y!BzkHQoW?*%@rXx->fZvAh>$o%GP*+aoF7i6UJAm_eT zP80)6R2-fGKN|NHD(H!K(E{yGI76ZWY#-}?31+KXG}d|S-uGoki-|ShYhC6K@&*i93c_u#s;jv zql=5n(x#)hgcutH5rn8X#fE>wEa3*t#*4LVOoj*7(dOIRQ1a6)|I^)gMXH*GRE65Z zyGr8cSS3=kPSVHPl_G*1ruWncj*ZLhMleX54kl&^xAm35Gv(ezecZR0ji@}5dGHqP z{wU{r+R>}RqNm0aeh1&lp3lJ1B~WZTS~uzo+Dml9Ud+@`tZ(oZ$$WR|1#Z-p(&q=F zd+^tGJ)KMD-O^N-YTC!|+qY z`oh6zeZBuI52Kik$A#aG$Y(=>h} ziismZLjTw}+CMaP)({n3?$uHon2#1+8R=cYm`^S8VLqb+6Fzilad8|Ryp&@AYS{o1 z0{F8iQ~`4?BmmI-COf2&GhIov!CG1uPYQ8Sy^GDn{V`)opoU{(^QG(lx>Uqs%%a!T z56FU94iQ&FC`xlx#i#$tgfwOor|LblG`+mM!>J=^AlSow?g0XO(M7xrcz&68K7~Ew zEjY2$eF#KK`IxQ{?DK@#@sId4No-1lns z=UR%!r{-DvQIfS>;K9KF$>5cue6TjZws8FMP`ZaEpN%5lhHXRvSxLq9(d-X>eU~&^ zB4?e6DBKD)$xlV-LJc3_Iz=Kg334K22|?MhW%=4r*{@O#xW$SuS*U{Sr=LGRS_J`| zor|{4s`X7BtHYm4IJaK%c&&kexwmY>)`ooi)7Kx4v?L%$ONPfjI$Z$uHob8^(GO#c z`aB9Q1G_n;VCAFnCtq~j|Ll{)s`6cjXHW5l#}v~4rr<74AM*5GHSkRw znnHyup!ooO7)Ycs)qa-hZ#|q|ZX?glbqCP%zR>LBEfiDxs60}xZ3+DRg3~v>yL!f4 z-PD5^4gW#)Ra#J4n!mT@M}2Y0PjcZ6=nXRJL+KEMyUnSobl7FU1ityAbi*q@sDl9B zgDWKV`6*#?_69UvF8-CUgGX#~#yesk;aClY*TkFAH_>>@VN3wls=X z#ER9eXZ)Z=Mky$3Y5yFNU;iq%hritoBj^2d-p4+$TXhA%>dHSL(*OK}PuIfIzF5Al zkBHI7{ev52%pa@{V2wA)&<8v9%Apn3up82}i8sa9b$IkEEM_9pjiMd9b#0yYn_#w;WnBM;?pwNH!{@VW`p9N< z1tYAi%`f{LgVzNP`sx4a#O0RFb3L?tcV&BJ+f}~jf--`C#O34beY|S}+ByVo`fif~ z0LXg^=?L(yRA}Y*xCKc;0rmBP(7V`skUiu|fFd&14HFZ@Z@hc%%1Pbj^*T5|`5;Zk z{=nIM-K@!WNN+64(&YS%!PA- zPdFl6bG(gX9mg_m1*v>rb{0`WOmL?Ddw#t=WcGWGt*OBk;#uM+)8BZyD3U|RDMu7*2Ar9 zd&buCw~LT+OTeeGv^Mq+Q>plNHUBG}t9B{>OTK1`W+QKfXS^GHVNW&_`*uCZ1ilZ& zmA8(rdih$%x|h4Ns}sNQ!b}#?7;oN9r>6RMzan!Jw_>)Zo36DU8+q|${NEc@+`~ZL z=xdr*mkGZ%X1_JB9?u!#A`3S*(D?eZSzm}XzPvROVP`%RaY5ecitM}ORIkWT-rc&V66T0yo2>I;^s(#>iYd+@v^z1;1@|iO&m=+22EZyE_QV@N3?TG4@ zKMc4OyF>hIMdFc$^wco$LQi5-q^$|^dTfdsR|tQ$qHLo^b@4oku5lneZ*W?5BeJ*#3vM3Bk|nNAfu5RH)l%~N z0<@zr-DkO~D+sG9IXh**51ytE^DCPIVK?XeDS$5Z9BhSR(3N%$WR!%Lcm|yBsmg3XPK9-2-jPLobPG6{FIM2>3mD zk*Q}!frB)79&xxPt0^2U9IY@=xL&G>m1hc7e#cnYZY(w!{3tXU`-U-Zw>@Kj?N9Hj z@ZPX$#9|UDz2z4JLFq#U_oH&xwG(maY1V|A!38&tcfWLeQ5VX+I6v-X$wfPpbwI@A zXW1y442?vTHoI+H%USv|Gk$1?r9k^_Ub4iQB9WdTx6ujm6@ib}e*S}>RWb*bk{Z}z|ELth>Ef4aW_)xT+~dd07eET^sxQ59Ot zfY@T)|IC!41@8zXY{S@8>dF^g`)h%0YQ9icg?5)H2M0o7es zfqX)FMMS(P>WutnUJ|N$ZV#{x%9{63z@&_cd<-4@y*UFEn+-Fym|`3>Y{9E4;|9ZS zC_W82(kK+g*dE0ss;=-N@*0yXS0<>(fMdqJ=P$n(hcL0|Jf-=Xm^Q4B+8+V>YZM<+ z$l^@(<^I#F`QmM%c7=f7W5coj$)So#2uUEja8=z*`FaWrqNIBALgM0Rm?`$6ci>vL9^ zGuvFkaYkOX8v4hIYK}xkJ+CmD zFPBTj`53>J)S)P}8FeZTEV`5J%KMdINtf2uv551-`_Jn!=}6e2buG9#MUyhFf%4NV zayYrj$4hU-U1D1@3H1rCRp3*>ZJbWisKqdJY-_cct0_(<*B)bEh5#T9PPz-7i;^##qeAH_zQGe*{mKgM``+5L;2Oj=KJd1JCp81H$vWQ zAFqvb1-0Bzs7%sMziX&ApPlJ;Tq6b-m)IuAHG;sfv-Q-wIkW~rTC8^fQ#y^G6k15z zD=9FagwhyQ824qCt+4q}Vf5FLTTeiFN^qGt!d2~ep3-e*_9|iEV~$l6^O7~piL^a@ z#3N*OyYdffiU~ujcS&e;LE)IB5tR=q9p`ex;g}>AW&D87MBP2k>B+*?hr5^Dvz=+K zsJ2ctj==Y?17KP4U&qHIr+Cti?&7S!WTKiiI*tD^@fP!%Ed8iLHl z{G(`rmB%3uGuLr{O)*Y%38R~uSMm>)%iJV=V8OEF{AS$4jTvQxu^;S5pfk@~H3=M=^)@7K` z$EOUAK(2{Zbcg&b7fP#vL}DZ!L3(rkPu@uYT=2(!xHuDK5877xK_w33&cGt5Y20{a z*`28bg#cy$FeR!8(US(paqNWNoU}UUO?IS4h}RTHe*DE9TM zvsCgAtSmwXXKw5iO!ApmAc(0uvPF7lD-LGD3EP=!M_lg%gUKH*l9RvR6PiTa`;~%k(p;lEGdMAc@_CZ%r__H z4sQF>{n$ot+C!fu;h$kih3a>FGnJ?mv&h_TSZNWOTGFzMPMHoQTUd`>n)GDiCiSf+ z>-MYvozT!IFxcC;8GFc)yGijpN~wMwAu*}6Xcz1|0W8j@2&Ga{e2NFIQLM}VCwnUe zn^y6Rq^FTy#D_~?v|!Jt+#IFhyw{Vm;{&P2>chRm&pk9pgu&0}lF zEN>-;&zMhZt3$oZ_T2&4n>+G7$aMQ_)2Pq4n9`M(b3>WWg-iTB`}w=o+{4O`nfD4G z(YeA=0r9y5!i$H=2_w)6u&;@%sfU*zUk^qOR~lqOd#RcGHHGjyX^g>|7ut-$co*9C zU>iH*-1Ce&zeVtlulskECgl$C9B>udjjo!3!d^&Ay4BD3Xvn z;FrxYlsO_F%>{bOVBwc9wsC!Zn&Uz1;5w*Cb=4=C z;5Vb)bj@L=;0ct5!+J@JvAEE|nI7?gr|cIg#w*U1g#_51W?R+j80rvaY4!d2!c)-i zaH^cM8J05WaKj4swR&ySA1bZ7G)hWRu&5|%v_qE(ojQ9X05WTrDK~Tje|q{DX3~9_ z8@06+(U2~gTBJ|a*9J4g<)WWPcWrg*@rZ#pWmp^xFym*kM|Y@;=&7d~8oS2y-yHhEyg`Mq9NlX0m@xs4Oa^uMD11U{{0wLs&)pzstYIp1u;cqom# zl@7Mk@A0fBiQuMtu*(rfBJy2E?(CL+IdN0!OuK6*xYSFV+?NU5_rKjnvCdax0m(p3 zA!`{5Ck*^{Y-gQ`t0FSd{XweF91`e{0{|@I2#BroLBEoG@y(_h&y7<^S4%>Bx0|0- zb62{e;%p``Mc1OtNU2m)6Hv@zJ(8OXMoa{?+>OrpXZz5~3}S58ojo zA~j_tzGdI={W-O>hxbC4=k?ree%p~`X7s+OEZwM&W%#9qrgIr56UYy7=v}hkIXHdy z-Rozd{V#wPV1>Cf_T!d1Rs%f#u|7+dwiELAyfzhmu)tN%d%0xq&1u zcAlMPLU&z3brFLHYplK)L&&jso1ntw3*}b9U}Y|aOYN!NP;v#HZVOA^q}xha2a!h~ zb14P*tlspqYH}cv8ProlDYQmwq-gN3rdDd^%afJ0WYXyrEHBu(5d5Pb-;&CTRL3%i z0r8uqk}7>OzhFEK_df$)U}{bG0uJ@sJ%PYIe{JNYdaCsgi|0Z- zC5|DFp8${dRPy3c7JA7{tQ12({doI-$R}E(B5oTu>2yuZ8`U3`7fE zc;3R*1~Gz@))XKTM06VO0_9f#AmrI?8!dcPrx{n*U<2^6vk%31i|w&fiC?V85TQ<>HoU z{91{}gR{2Hadh{T-Qx=HXR&<~gh<=xKVT%?=PuF={QbWk(By8R5wtAwv0W3T?AFjeP6-cwrlRQ#*9&|XOm~?*E7Av-sVVjuT2Qx8= z%z|<+X;qHdh-}UiCr78_$VzTf9x~)ihfhh!>1rXADv<+J{bnTHuh)>sI=4v+Ay%De zu?3|>3c@S+g+LAMMnyyg@I<|v^DJ7caqkeY_$>@l5{lT zSc4MTj4uh_9SZo>Q5qL$u5&RYmhKe&GGvii4K_Da2jbwxu8O-1=1gD9d#hZ-Rt^;Y ziz87*8EH)jx`L5NNtoJgJu>eeP0$YLcS)7r_!|By?*)W~P?d~|C&XLRG*Erjb{(2- z8w2bSZ?u2RIFUW`?=)bGnnhH>UD`)XMlb#hFO4dT%hcIO&@Ixs#^_WGqdu9 zM3XH#VNHjvshHUl*e=8+{T{?^WJQ;-7DD{^*rty5J!N&Sb1;8pypU-AVTzK21Ziw4 zqaupE)8@J|L85!te{RE|BH}ZQY{}l^U>0xqIw`p()8N%7z(Jj%wJW8h3+#Wm?*f{q zN^3)$i&RSx#^62ef^bKwpHcac>}x?Bcf|r>3}Q;Ha=m^g6qE3zh())m@0m&R`&7`}z_WWg&kn6`xgzVCr>!%$0i!+?M_8kafyTCSdEdZ&pwq48`QI-Nk4jMEyfY@*w-tye4HZ?Ml&?ML^FG? zH^6Up5JB+UDX%AxCj>TIn+M4Q+Fx;^^l7wBnzTX5+W~Qc1kK4ZZKYO9kvVWi(##Lr#}3T<=^u`BY;vGt!9EiX=Jx0Pixhj1~MS4TC(`rEUivPx?Az^X=Aqx20DE#QhdO8g3$qB1glPpv1OHC zd_;n)LRRNZgRh|XKCpwd_<~YMnD<4y*qg&s7c)*eF1+L+>od_D;iQB|oycimQq$9b zOI!0chPFc4Q?G9yY^H=M2&*8E1QV()gj9oOE3{{^9~ZSr$#pjk(1jn!=v?iPuDtt% zpJrb0DX|DV?#U)~b`E!y=*jyX0w7;LWPd@y2R)MYUZq-uZaQtrsh8+2(#eZVsToKj z+8iiu-O*#ViRU3_0)eT~3F%M9gNovdoe1m^ziC38CO^50naCIDlSVp_FyFsLBlTjDnyIYS3k70@YG>Su zVigrtc`6%q=YqRvo1_!jF4k)95}c$j#l{pQx}i&}5h^c*2vU=4iMXXuy` zjLMWy&4RT5<2;4TItFmAwjLZxN;BEDOsc?T#1Oyw`D^rD9!tqc`X+KbLs{Wf1ZQKk z#s{xVq~kFTUvYz3IZ9qI_{2eGzKrHvI7N-}f7jg4UO+Z`Az}f=5Y64$Z3Rg4S*hdVaL^d-L>7fn7ebKzJ za`f79^;HnKLekVDJoHbXIs{9%(%wuY_DfiP9_LZ;Aa3bV@aWjP*nyuW{uY&pc!mrs z1Y4Mk(H0|4qLEWX9K$zh&%oT9#!^^oJK(oHt3*vW()8k??iVk?t2Om+JaSx8It%W( zY+>WU2jsJ=aHUo*k!6L&44>=gVgK6G91Y zx@?y>)82r(6Fb{-bMWh*z zQ?zBprFa?S_!tOMvEKLWg3%p%9!D>q@PGl6C+msQv$e5xIRT^*mBMT)`am8b&)BiP z2O?9S9~3duUOTIr1y8yx#C&qy7K9wIO_pNH+>#Bgb{~YaH;m!badpB$hg_jqOv`k6 zojP)S^0vqXVgQ7|KW!Y{jFTW7Ivp60^auUHk_mE-v$mC+JORVd(Q)XK7Xgk|_wp zei1U#?;nmQ1_m1eyc6+TXsh9=EJ%1~(Qv{7HDYaeU{OL00?;1tl?yyJ$_fNCjo&pF zw0(*5Ux(Nx)6M|&+vme#8BT{rtAl(Cq{8d4%fduQbTDMwXd1qpdQ%YjwAR~Y?Dgi8pM6PEb-=GkZr zicJ?Ia8hjb!C8OCRLVmHBhxCf(e@lC{4^;>_22=j3jc0ld2X57#>7{F+0WvvvC~~b zmm!;E-YvUVks)i__R%qDTDm}$%q0u0FT8D_y*PdK{D2AnZYY)2&S7)PZqI2-la9@& zGHox{fcd;N8TbfH9a|uq%yHSeA|}Nx!OxHQ{kIpCtX_$hg>GRH=n`p7lvHejp9VEq z7>#H!3NG_Bdvk1&Yk^WS3}P_>{sCcweu_NTqBOLk_7W_0n*?Q~7hrgOAqu&a8?oGw zz&Da*;R0RCk5LAVn8Lf7DMBje5e-Yr%-T%C9vbEvWZFSCR5!bl;h|V0SGue#nm>kD z|K@}fb;w;#nNi>Ve#~tTr(~EDueN&M>hWS3qwfS{eJ9$UWiqoqkK4FsF(vzOiO&W# z;H9!m{%m0&8XI*^MDGg#9rDXp*&_KUPO}{I)F(=s*!UaX@n)|{$m4N%9$6k{1|EYC zD{V}U_RdnBw=UFCIL0!o4`snaJ^9~l|6+bk?JyicMCb@Cc+I1 zi%tQ2Y;?qSSFo(b;$3^{CN?#zNogS7H9;tC2Y z&33QHg_g|9w=mD<2tf%0e?vPtd(i&4o{H0QsFr60Zi&5g#9ZNGU{`JF0l}IP z8vCm}`%dOn8rzLgd%phXrq5$(^+JT(wIM`)$R0ZD#(XS; zW0I$?hCsn9;AEuY(i&1ji?F4RCNvwGtiar9hBlo^#m3QxK1ku2*lJEkltoRCm;bq8K_{F}B6 z=(xf%X7Q&Ncy7qThtTm6!|%L@iII~0f#&j1>t@);iHBdD6U&6S5_r!;8+_(7#5s9~ zXmHh2;#Gy;X==6r0^5~(U`9n!$kt2LdHory-N702!`&qnI0>%LAVtCo$%0TE)u)i( zB3IFp3WYbtz_r2(zRn!Y!J4vQw)cA_5MTCydWAX;C~1*)t?+yt$9*9>VJ1G=trQ;n z1BSv&-NfY>G zbBL_LaF1Vr==GEzHS>B;bnYws!Zz+T%xETua;q(3w8+HQt?e6qXmBlBP(;rIe z>*SV(UPyf5T*>d%x`MD%!p2lB7=h>Y@s%iKPh>IjR|_sMaQdW~J)g)8;i6-JlG7ka zhWki>Z&H5P_cEK3i*5uinU7saQz}rbxw2K0?dwbQ6BtXt+)=jtfb}|0eU~D84x(XD z8;tZ!5`3L$=sZ+6c^PyQDG!4yyyiw17on$6u+m&joT(SlhbiV2SIojdl7s)C9(pXFeVs1`MXtWEbZ8QhR>{H$0=(#oN&7259CfOkw*E-y0x7}LYiQ22fwH;C%lT=6~3Ior|+#?6**=mao)r+BBXMTtBy7fy_!ja+iF>e$Z@R~Uq z>|lC*0eu{kQdS(-^n7_*2B8FNtYhLe?XPyoW$eOP z@1Ks5U`elIlSu7*(+bre5qEvbb?s4WGINV8@&L|dSlgPwPx1w^;H#XH4;s9i5A@WT zrgPbNfq}Ea5!{NT8b$tEDjGQXiXs&s-cNWm5dz4^d7MkO3pqyHIae)6p z8B-zjG!_G#?^UC(uSz>qHrJ4r0#fue4MH&kv(a`a>n^j>zSfe10~P}z-3#-sw`0}W zVvj{lS$vDoXyu1faSU5YpdXkI#=r1b?3(DFYi@TftZ*+sz6>)+_8*IJio!P$5Q3lh z!1}Fo=V6nx@XI%Je3aNE8fR2*UL7uEG>p}{MX0;KpeTsz<`r~`N{>ZLbN7JgQA}M` zZ}c@{>+CO-a~`?K*TwE@+zz|W2M|dp*lfp-6cJaMASugbs^}PP#AfaF zw$|X=BFl`D#k1fZA;RnnaK4aq6^pVG;V)7l^+w{{MENY~y4hPUl<+_)n+isnkb0NM zX{S0?r2oaji+Lz!d)4M70UH&q)))1{jlK$xyXz(V5yFP^RdLY5*ietIk)5if1Q)I9 zrCTuq#VXzWQYWl3m0Iwb$~5&CmDDoG9vATLY~Gei)h2 z)vzsKaX3{R;7P`|;RL**{2WK@1Q+8$`&qNUd4gWcMBgo_c~LBfap^(_9??g;^A(ei zQ~)aC>&cW{4G4Xn6|AH~5*ZKu245WZ&VG9Bd_^Q=+pk9C4N=K;Nco@RBdZOdwu$#F zS_uOmiB2?D$sRxw10e~*s{`Nzsv$h6aao9GqO-zqZdR(Ch0Qq25e$rSG+}Z5gUPxF z#g2xVdq1`!zMYuHM}VrCAU{Xddc&@_IAG1wmA9~Af3C*!(Of8ah~A2V|3*dGREzlQ ztY&%%i)E?X(@&#zB-{*W`{er}BSCzWS0W<_)$#!k2YPjCjcJ2Ck zW~M4GHE$!~X65F2GeHq_k6W0*kx*-Fv2zM1&(Y2=*^kIvU;xNjcBkg3;e85A3F1>~ zwp30G{NEfC7o_E^b8BM|NWE(@ebv6B!6o;;e~D7((7SKZzgi98YO{VOD2DU)E0tgZ z(QXB?r!VY@NQi*pq1|K7p5V>S@kj?HctmTux1Rg)See2v;_B^6_YZVfQbSKpLTGt? z=N19QB6)76R8MxDt@D1#~Ls11b)~y zb=ZbOwFArInps#MKY(qmDSO>nkmhX)R1}Tn^b6%ecE1#X@^s1G77H43(T%@0pi+QLTmiuD>jFy2++^c2YC}}9PfrPAbODJ>DBfnARC(3 z_HkRj3ieXVhM+r%Lxptc^^UYG`S-A0Z$SVm`hmZ_P8(oTDJB#XAGRso=F{|xoZ z5&!vx+5Ov5WX>UkoCAx^(kHc7MHOS!dpK2tFLXXvq@NtV!VwIy(GDK-B4T@}xvAPS ztm{t3#?bT$oYdAg4ekoVjXuHam{l0)4I4*V9Dx}Frw2V&yBrEUYF}?(3=HXPOSJfE z%_*mNjRo*$G$2$(+j^t(q#ESmYP0j7lCIc|VsK{XjJ<)*oM#fVgxq9Mo+fd46hgo} zn*>Dy1{qs*mfJm%^yOpg(jGiK;!6yd2YPmC%;jt)*pVa|tdC)yxrm*Y6LaEakDm^5 zcO^b=wbgMXCMKsxAu#P$wB;`5@w}#!XcSyga8TxD!UCt2Kfy=yc$GjH=_AjXJ(&qZ~Cu37O+s-4S!sk{=Mi=t|l%Q?FUA zV8KY*`JGJ;Y{;;VqqA7=;vr2o^fd&6m~mw#N{0fJzktaNMeN=hc(gNfcza^(H3K~U zD0VFwnFFr&qXFD6T=VZZ#fRV@5dsbPDdurE5NN_r;wz{}+Y|dUolwL%j9=P_%fHrv zO0zbL6z$NWv4!TqGb?>%8cGJUDlGn7WtfbDjl?Rw^@wwCQ_u&ufG^kG9g48hKq+I* zKw2N>ROW%{M(*c|#8UjH*z+7IDoO`H|2(R=sr(_8244#HPK65%OC=;^>N>N%@Z=Ug zwdrFC#hJJay~ROm9rp<968lAVyL{$hl0+^Wt)x$Al_xkzcTUSa!n|Z}fnBem{pHwc zf`tjf5wuPAhTNpD|Mey#{}tENh}LSin1`>$6WRfnHlE6NY4x&NB+mz!&a7(l1n5Va{UA1j8(dB)3MT&u7hKOkJub2@Fgc)mse0nePx(Q{{@TB6rFwPGD^k zeXmoWG4nH#sg5F+8S*%J&w{59Q6V@IOSXY5hZ*_p$*K4|uCK{TC{J~gdnrV6^AVq~ z;?6G5iL$-r6l=Okt+Mhr%eEvufbHBc$rqVIVdWHyd7D$ik093U7+erq;Faihpv|C0 znSxKjJi;5X@KTM3W_jKSzz8PPOOkf^Q5M1M+cp-^;A13Q z-Y^-74QV3wUHr_{Z?c0iX?jZTgn~OYjIT95e0in`dld;}ejAl2el2vm7N1LikCAVL zLc;erHGGj|Uyyd8riVx`k2Vi{TaOu-0FM~(HHD5N=4!g|yiugSN}*p&r^(kCSQubepTU+xgEy$PqUCXy>li#vpRk|Mmr+4G?@WEX!A7fRPi zHCi~!t5V5u*X}4#%)rinS|Iw#!#BGArq}d2RrFt=Dyty$QSibDFmPF$NJS~j z(dyq&IU2@gst(ZOslX|O3>CazZY0VJ3*^A4CWQ+j;GNzlX_!XCF@00In#(%?D+GH} zSiT+=2v;R;p;q&d&#oV%O%pj{YMgZUMYEuJ^jG&~!Pc#io`Y$VSlQv}M2>^%mU_e{ z-%u6#cwMD9w`;JNZ}b@#&)TNY(PO|F`>X|l{od~EBmVUM*LKfpdHrtG;7al{9d7e;6sVT}V;7NgEIdsfrP;Zrx+CFBE5VfLV@jEa@Q*Y-jl);`$MS zghar@5hZeBUV-c!sZB!WlUAnFhE%K`xTU(bs*j=3HvYU4(Wr7J$SbjdWjSzkD11Y# zjI%sX9#hM$Dwjyk;nG~HH;}h?&r}$4vOMr?;Pl27Onacy>hWQb80^!lcg!j`iH|%> zPFC>YQ1G5THVKccb!q;1p={@0q(!P-TYd}m&3&w>; z3OIu*C!*KWkvKI|| z*pI8v3*E_tzBxrj)sVUcILFA0cD@izD8SKyP_mH4+_tUGo361~>*Pnu;+KU=(WVp% zo-7S1DSRw?O{H}u4~ zNXXVjgIHqyt<&MoGW7acK7p4h!uS)3x|X zO9n!MpyZJXSpZ=Q>Ia;*XNzXW!q`qhOTvPqW^zI)|l z6{i1YcBg`9T$aMA&vbHu+qY>AoNn(6_%Zb58;>lRxE0Uav{xs`Cj52X%0jJ92b3lg zPS?sV^O{}{X5T6xcn z4YY5TzON*snNl1aiRtF*+o_=~y_BZedebd+0M&&T(lm*(pdB*1ZQG1fT5W&}UuXTHmWUNh%`(X5+euLDF zv%%Wd6$5@<;DADcAO27%(E&<$goArMDOoCv*LO`8cS57W4RbSyHxq?Y3R>8fhzn;J zT1hS>c)>Ge%s6`ZP;j)LhV>M-#JXc*-DQ9~CJFxn2_zC4}=Mj_+c1@FIQLR3_ zqWlX@)8m0+W>kn3&H)4|!4Wh(dfvU;_C26dX!A;mm3}5t$(AH&WnF-ThVQL;95^}W zxV2c?OSJSrNY!a+c|#sse*nGa9UYg=Moj~X?4gTnlm6>-PLgg(#+9IcPE(Qvt3w^L zwZwj=&-QUIeU>VC#h*Spts8pw^`NuGx;b8b#DNyqV^V>kb_-dY0KIxfk&)sl4W$rL zSFJ-M7XnG DlhGISEt)Al*lr55+qgyZV2&9=uR>z|YOkfCEs1UgpZ-oCOWEq{SdwNyAY&RX21`R-QeJrh#)Cq-{z<5$YOOb98}2 z?JUKS<3xpMf{V=B>bgXQ+jeMpMsCT3H&3PGPbO{<6i66BBipHCmN z5aG`qQelXjqRv^r7;fN5b_j4w&&#gl4AZ740WK=%gWj?c`c=(gmX@Z%W2jMU4_ty9 z9gUvuzN9L94Y8G2QLU=?Y3l+irw)9V;esg^}9Sgm)cnsc3>Ei)jhG%&OaqcREw5_er zs!U+k6}8MxT?1(Kc?y9C;uDo7BR=5#g(-QssxYqF>2u=Y*S=l zKALID<4V4|kQZC%U3Ns=0qdOC@E)>j$arHP z+7XGP2js>~t&I8-J4lwC?V(vTJzN7jzLB#tV0cCbQuH2l2ewg^Rd8YZI_b9V_Cg5Uuu1ZM(}_f zsq>rg2e>lSCf~*pnW@)r$;Ck9Cj3nHB+E9Qv^jco>`=LWm8rLgC(GHA8iXJ>m$yeK z*S~@L7WVaos;%t2L`BV@f*^Tp#r8p%J{!UbKXsT#A>^R$}YpUOEjbcE|fQ%GMCuj%#Q?3N|M>Z zK{i#r?5ZMXQ+v|bK%Wb_t>M)<0_5rR+y>^snJNpJrt>0e?Ia%K$m=bBwXS!IP){*| zbL_%wL?M})74g|a@6ab<(QU6!F)~JPDGpA>@wti0xN*Fek014o{LLAzD1QpX#72VaA{EAmfa(4}FOzkX>sDwKPUa-DkAW;kM9MZfy^2mtIK-pA!*JdS`Qj6+CgMAHa6#+n%+w<2ECQklt^@>Lqit=< zcUs3`e5hI&%Sa`AL)JPzds)-}PR+!!{pZM7* zX*dJ|W%n{W=_-NJ>KnUVeS2{|Z?)T!L4v8vYS+Xci%@&Ed6mWo%vl;aDh?1w1NK9D z8eBGWx#+ZK1;{ruX)jM$^h3qeqA-C0sql8u1S_jZ$S)lR=0oAAh1bTmUdCvOv*Ek3 zNwL@Swd>>4y$9xJT(yp3pYeoX_#=B`*oEWVs1zqtJuZ;UIXOdjhFQl4Hg~7#qd`?< zD?m|y6P`k@6~jSd9*l>SSQDUVhDIEPB@^q7yg+?d{UL?lhcYM!T!Hu~a#a<(D4d_g2+TQ8{wP=I3NNE_f8iX4!5-DP`T*-i(C=o;*(Ji{B4 zM%errGmjBKfQpZ~_nMZ);FB+~gfxsh@<7=*HoUzCyE5Iu6Mo3}(LgP^^k`!LE?hTs zGc&uDxcr*!GS6x2`jExTTCpW(p%h|cO}KNaAut|8!8DT(_Lw1#N_=QI@s?A|aWjkz zB;1d=F@q_H1zd$t(BAiVHqXN0vm`FYCD$|AEfIea+SdVz&C1q^yj*pWBM<>5$nGW2#9vdz3y6dkAlYW@(xB z$&oHs;EXOHVET!K6I8JUkdXfm-EJ9FSJZ5a;u_rDWkYa>ARBjgclY4#5Q1B9*NwZo z1^3|Y7Th-W@||-}oqDg{{kPVdUDdODc8?yjs~4cscuPuN`Ol7J89Sw|C{_oA=VoWe zUg&IrAz{`+y;%yWh=&52(8V3WLlP#xbxKl;)&}IzfpHMcIdLsP?2x9HYwCp*zBW{T zU*;(j<51cx#j_;L@sIII0UydWTc$CoTEoLFu{)nK;;F>Bf{&gx09^(F&OG*f)}f9N zTusHDqxs=7OlpQF|0-1tdm=i-oh;ccwZvB{g_-tWLMia{V(2furv{$UGk34&1g%&p zxv~I3H1yL3S9hsN|9y#Jrn1}nkXgRJ-x;~?pMmHZ&UuR~+}ixv8x=GKi4M^MC-aRq z!~PRM>aQs|jkxmRjB9o%w^)|t;JrnNHB7>c3VGJU$=|*Tc%;zOG4rIHiqnNX{Dgt5k)@1m3)1 z>I(K6?w2jM-4kjV?yuQxc*W|~x*T);?H&nT_WI9^6@HC&YsJmDKQ9(xYwLa`0XTRB z{2Vz5TW@Y1J?#Df_AZj-#iX_ucIj!lC#_0<3u;{G4r0fG`sf7}(pwxs7CI>Mol_mv z(9tCRS`w=xcPpk-&xqi)*& z`7PK;WWbsZbYR_n_5v-ArM;?7b<~5emF-fqYb$!(2JV~Ke>QH?mjqu=sZ9~JjhYB& zhK7TWmiyE%N)T7s`+qC7C62Q(G_iPjX5Fd| z6;nGo{2ozhIbYMy=!tdHuYc#^b|%y0F#xX5L+)XY2JnSDt>HO~!Vz?s(bFA3?wN%o ziAv6Hn!$q$Z{HAA`pz}fZ9?xuXX&A)1M3jE!XrsV9vYR&*PqSQkK2@G+C1lfcqiM> zrEzifP;A6fHqZuPk$hB4%9qQ^_EY>lP79A*6zwq*yZ0F_LTAT}dYhhqdXqsfTBwvp z`-&DOVIfXN5d5-kCy|;QAg2gaJP_`Qw#=mQ1jbt9!J}i~N2@n-R3N)p& z(AQ^?NC`HkNChg*g@?%DRCfs{_$R0#W-?NLGQ)azZCBvrKCzewcsB5sR9`13Aln|D zAgE0Y=y4-^;sV*NeMJY;MSSWJ@1-C)2w+pBTl5n6PY`00blZ9lSCi>$9Oeo@kaT#A z!|D8S{FHt&NnJ-0y&nBsfK)R{=ONc~xb2I|XP3FSr{19$c|mtA*ek@Ttv`C zn4SE%Dp-8w7%Ui80gXve@OC+;sBavC*?7lTJM6``uR6m8h;hm#8$e4g4utg zZ!UKHXME0Gt!VAv?x>3HBL|HPSqV*a4=9E4nAqlp5m4mfTK9f{PwycduOX{Bm%j)M zmKK@3Wr0RaZo{b?5Lwn_jzmKzP3Yp7PADr6H<{lY^Ji>c!YgBxlwLBs zU6CBY$@Uw=ut_~$2O?6%B!A}S0DEIsO2g;0pqa&SLGK`K1ukR35K;`5?4xOLukNE*0%=W$RB z;K=AEzZ9w=@!0Sy(tddD%5vy@b7GjOcyOglo`t5D&a3g*>dq&gBo}LyKaBe(axl82 z9*u#ej))BgF1I}pdK&KM5+(xDLko|yJ+oE#e0sm{Nm947j?vB2 zy^57z_r*zO+~8rx9aPBMWN2j&z&cW}IRWJ6r+kxZ#i{H@kXMK|b7X@93(tPn4pLy! z@T|OL{L_p}h(ZPgG9{R-dJnVXg~crn?0b4CF?WxoF1I}r@)+)?5dw3n1s;iSjQ@Vq z@wKgT9?Bp8br$|4PV%1-l0rA=#IZ1#9>}=Is>l%b4;B;U z3~zDdnQD9g=Ld&E$qo7#{P2to_ODjJU*+NsmSV+voD83N%^aJl=3|L>l?d;j`UgE< z^2tDYuRTH{MO-4t1MCSdRU?gmMssLR;ihOWeuekPgDz8qyrFLfj0g zuVU-r)+G3MO?Jj8(5Avc`u@86-V_uht~VRK0}$_+gdM^UIgp{LchWQHwOw@XDKO!JmmTx4aq3;v0UW z1aJ#BqtV6iUM1?*eQjYZqx%5TAMbeZhR>dB@i+$oLBoSnh>$5(-Se^^SJ)uQ?|=Ix zu6Shg^-xhQ=07|Kxov5hMw~s>^PPnW48<#?S(bJ3H6 zY`r01(RyDK7U)TVeA8?#d?95OtgALH2T=jG1u{D|=z*bS#{cTck|efa*6Y1Yu9+xC zhHZ0Un9obGYEu+Ay-3Zq#4nanzkL;b%=rlEO^3Q^2CrZ22H;$h#KMH3IvPbdb>K>- z3#&hY9V;mvC}$FB+Ji>8Y~PM-)J6feBUb`wN`v^8IoJ;uh1BVr0^iR-oT>LoVJu14 z7MMMl6qlT)W>w2JcMn*V5r$6T--nuhlRE6bD#jp1BZvX?CBe z-`J?sv)m$+2uBfhL+^g&8Kk)R3%s6y+$_mdB871JFdUwsXm=;5{b)pag(DqKZf^Ne z9%vD_X?F!M;vBWILbcTehP#wP{bxtVCFz=Be=u#u}{fXbPy?0{1}Qg&v5l z1-Y)s{h8NLB!A>4)cDa1s7UHEqMu$bFiQAbb8H^pel8a5K%9 zlC_x8=!Y}$gc?=t|2i(=a2jvPkN9fgf$Ux?h66Pfx;F5UTDY`8h-UYg`YkEUDEV3s zGeBv8j6>Am&XfMoIeQ1=qAO)kLZ(%A8lsyaRwBM)Q#QMg{8qndy-?{}h}$6}hAQGI zy^Ls!pMd_`7P(#@xqycLeu?V3^MlVPn?karYctFM*?}2K zVt1eLZ2P0zpVnd}Wp|U`{7);b2j5mT8LD6Z^p@BJ;8EPCPKSXh7}{!%{f9@#Q0d%4 z-UVH!ZSaMS1dw+NsUwQphioqh&IBih%CnbM6a^T0KYrjmh&GL3=2)**GT5b~4O!lo z)`~}-pnQ9~Z4ksTYEqg!b*Q$qWIT-p4GQLK`vBgK=7?12rt&Tq{rYZlPy^0jXx%$6 zIUWq}1*?+(Ke6>A;jMg--~2g{=kh_k7DJ_=d8+mZkK{_kh&Of@;cl7=uOymv1_-|U zchoy%xD6@mn$?m&vzCwsez}r$-li&qIDYS6fBxTKbM4!9cq@MrGWeuG6sFz%mqFQK zMoHIpm;rJFS2E9LlH~%80(H=F#}F0Ds~*#+ZS-T9PC{Q1pOTL#E+4w0?%anEa^xvng872pX56}1b%ssge76A24`&w+3=>`5@0*V3RFS?tsqPp{n z_w0KiHH0-?G9g`w8$B2Hkpc5eEtMh^d6>RY>3<@s2_cPM#4Gqa;Bw?Y+n4ot*S~cJv{iEek&s3j~!>Yig#OS-S?>H z#g*n%2xQGbWf-xl@2v7E8YlC}Dk)u<$&>XOq8|RJ?frO_j(PuuWA}blG_!Y=BQqWP zoHhpi5U(3TH#5@?P>^^pyq8GniJ8}8po!>|PjxXG?cWkVU8naiQ{B)l4vDqrxE!R6_t$<6PTvObYo$Oq!E1 zIUg%vk#^%mD5Qf$6QIJ$VUd7H*df^TFaTu)Rn35ehTF1|mfI_IOsfUq`dh)JPnDlK z23jBUt95}}O{KpAP8|B?(Nxp^3EtC~k%Zs^LemDxq>Q^GZ*;D>Kag zf-GVqJL^W&t|ICGJ=y8(vws3FYGf*N;fl`c5?bbe_W$g!It*WFQi5 z2=-TbITU2iD1R0_tbV6$6=y=P7|CDRrb;1QfY{nsa-WI0#eD?OR(-@83y_>ZaeB2s+T)EyY_r6aAeg@8bKKxs${haQNbtO93%4_@kiL%@mHhg3K zT$JWm{|)e2>Tap&0)g;?chRo89!@`e5M~#gVICf@yPmbj+q{X&iW~~rNDiy+$~X@` z@n2D-(XfKKvi1D-=Z6;7&sG=>6VZGIX`n_z2#6e342WbJf*k|{prw=UA8cB@X2og! zcqet47#2V4R{L7VA3t|s>YlCXtHz%L`0X;!e6ZEnf6tW1D%&rH;1n9kV4&ia9P+o3 zOzs*T4jK)JM0%h}y-%tZrL;r_ccsOiE>Iu*uH7d5zy6X+fQloBB?cn#hG09x0Cv*T z2zg5>x9kcfdMyC7qFcUb-f9q!s+3LSg9L3nB{p~(()A+}IK?~U(*41uTzY)&O7`)g z!J*EK&gjMVjnqvjcewNZlSQsD@LI^fG z41oNfGK9bA544Borkw{SS5i!y%nxcmaLwt|(I3<8e}TZx)R*1p+&ks`i%xT{=;~Du zkFeYfI@NwE7a}l@2Y-sZyrsMQ1JoZ>F0&s8XyEMQk*R-t1bg&SyKR*^BIDbi)(%yb zIu?P1#?z~4qPIMa2Bdd@wYH-gR~E)-kscJq;I!i!58`{71X32~Gy5;ZmHhWBOOoR; zrzbmVQo_>JMMal>kX1r5)@UY(A;t#vMFl!9CAp<`S2$V|sI6|_@J{*lKAXydg>#MK z*I2GFnuSsJASZB}d#!(TPPY%GWYU*3SDZ^g-M=OKIG&x5EkF8u7x1UNrHk}#x%Yd- zW{Ql^JyukExZkZHsg{GGzwDAYqLSRj0_LEziwfU^T0!Ok>7KO1zbL!i^(>H!jC(VOJDS!tQF)OWse89T^Kj5y0f{1YU(YK7!nj%KW$*wHT>OX9p4QN*Aj|co?E&g@3y*b zDP+tX-C*0#p;L1`4)kZ%_>~QTzPEHEW!oUo5|q+YG34sOX#!em_&`-M#NqJ%-di5`JwVSYIRR$_mz z!(`awPoZ55wA6VMnTqGm?7jZ%Vi%t$FbXBPSDfMcd!sQA+n~d{?5pZGx&7SI=hdo~ z+c9PwIyvgcc%V_r($r|Yh8W^X2N5l|XLoQdY^=y{*mh5f4`5B)3 zNkX&9K2JSCKyA5yphO!?!ygac;uS(#2YKMrW zm!ex|!P!M(_?8ROdFr9%GpuXj2mDmq0i@Dp`eF3Gc_JT48bgcj>SW`J#=FTV(3P?J zcx|k0dN)|on@Zb1tHWvq%n@dYHID_X62gw#jD4eF$kkjoggV}P84)K=JPfe^yksTFLOLT=A&8II}#qS88KDxVM$kn_)8{CaEP@fkGtiP6ZrtlUaI?%*<{4u&l zwTw1MuC+=MXkXScpkbx7^RDfyzFr1L1WMju2~)H?vk!NNyKvy^o8j&n1w{)@;Y<31 z8p0PeB661CMI|B4E51bLiXvT6(ej%)}r_I8j}GCG*DOx%bHt>?112+i8)dNc?L{Yz5W&&)1yKZ|!3! zPw>s`I~Bj>Na{O@L>%$av*7^cUsOftllfLHULAuvSfR?1RYh=%jTxj@XV#K`U0#^Z z$^8hTf8~Q8zgaYskYls%%~LYApt6r9F`0iU61(Rq%1T=LWqfrgB>4DiJQu5EsXhOFqfMf$mHLrQc%=@ z6;V%(L}BuKIoK59hqEIu1iuc_Bc*x-gNZ6+H9dnLX+;vJy;e>SK zE^|VvS@HiNb)80u{Z-SdoToJioCH;Mmnc13rW7LPSrang;oNg-k zJ2kN`>)f0z%ifNV6#4mGaur;lPEgxfJpUPKyi-rwwU>xP0+ed!YMiB}yRXeL(WG^^ zG3{gdj5*N;L!~czh+?SMMLk9cvo6w*=IB0%>oMxUUG&2_vb=JrDH-o8nMBxax3Cof%0AtxH6^7 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 cFQ;{eV(qXKh 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

mD1>`T6(s; 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%0D^Ka&OpMA;Ax7&(FhDB_EQj9=ETU9W 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 Y!JsHbBH{n*uzvSrpwMRnqK5?fAJ_)C9{>OV diff --git a/groupy/__init__.py b/groupy/__init__.py index b233fce..9e4812e 100644 --- a/groupy/__init__.py +++ b/groupy/__init__.py @@ -1,7 +1,13 @@ -from . import gp_loader -from . import gp_tool -from . import gp_viewer -from . import gp_convertor -from . import gp_calculator -from . import gp_counter -from . import gp_generator +"""Groupy package namespace.""" + +__version__ = "3.0.0" + +__all__ = [ + "gp_loader", + "gp_tool", + "gp_viewer", + "gp_convertor", + "gp_calculator", + "gp_counter", + "gp_generator", +] diff --git a/groupy/gp_loader.py b/groupy/gp_loader.py index e37081e..98bb7a4 100644 --- a/groupy/gp_loader.py +++ b/groupy/gp_loader.py @@ -1,5 +1,5 @@ import pandas as pd -import os +from importlib.resources import as_file, files class Loader: @@ -7,23 +7,14 @@ class Loader: A class for loading internal date of Groupy. Usually, users do not need to utilize this Python class. """ def __init__(self, ): - basepath = os.path.abspath(__file__) - folder_path1 = os.path.dirname(basepath) - folder_path2 = os.path.join(folder_path1, '..', '..', '..', 'groupy_internal_data') - folder_path3 = os.path.join(folder_path1, '..', '..', '..', '..', 'groupy_internal_data') + data_root = files('groupy') + self.parameters_path = [data_root / 'group_contribution_parameters.xlsx'] + self.group_order_path = [data_root / 'group_order.xlsx'] - parameters_filename = os.path.join('group_contribution_parameters.xlsx') - group_order_filename = os.path.join('group_order.xlsx') - self.parameters_path = [ - os.path.join(folder_path1, parameters_filename), - os.path.join(folder_path2, parameters_filename), - os.path.join(folder_path3, parameters_filename), - ] - self.group_order_path = [ - os.path.join(folder_path1, group_order_filename), - os.path.join(folder_path2, group_order_filename), - os.path.join(folder_path3, group_order_filename) - ] + @staticmethod + def _read_excel(data_file, **kwargs): + with as_file(data_file) as path: + return pd.read_excel(path, **kwargs) def load_parameters(self, parameter_type='simultaneous', split=False): """ @@ -31,14 +22,13 @@ def load_parameters(self, parameter_type='simultaneous', split=False): """ assert parameter_type in ['simultaneous', 'step_wise'], '请确保参数类型为simultaneous或step_wise!' for path in self.parameters_path: - try: - step_wise_first_order = pd.read_excel(path, sheet_name='{}_first_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_second_order = pd.read_excel(path, sheet_name='{}_second_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_third_order = pd.read_excel(path, sheet_name='{}_third_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_universal_constants = pd.read_excel(path, sheet_name='{}_constants'.format(parameter_type), index_col='index').T.to_dict() - break - except: - pass + if not path.is_file(): + continue + step_wise_first_order = self._read_excel(path, sheet_name='{}_first_order'.format(parameter_type), index_col='index').T.to_dict() + step_wise_second_order = self._read_excel(path, sheet_name='{}_second_order'.format(parameter_type), index_col='index').T.to_dict() + step_wise_third_order = self._read_excel(path, sheet_name='{}_third_order'.format(parameter_type), index_col='index').T.to_dict() + step_wise_universal_constants = self._read_excel(path, sheet_name='{}_constants'.format(parameter_type), index_col='index').T.to_dict() + break else: raise FileNotFoundError(f'Can not find group_contribution_parameters.xlsx in {self.parameters_path}') @@ -53,17 +43,16 @@ def load_group_order(self): Loading order of group for groupy.gp_counter.Counter """ for path in self.group_order_path: - try: - f_order_group_function_order = ( - pd.read_excel(path, sheet_name='f')[ - 'index'] - 1).tolist() # 减1是为了基团序号和列表索引对上,因为python列表里的索引是从0开始的 - s_order_group_function_order = ( - pd.read_excel(path, sheet_name='s')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - t_order_group_function_order = ( - pd.read_excel(path, sheet_name='t')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - break - except: - pass + if not path.is_file(): + continue + f_order_group_function_order = ( + self._read_excel(path, sheet_name='f')[ + 'index'] - 1).tolist() # 减1是为了基团序号和列表索引对上,因为python列表里的索引是从0开始的 + s_order_group_function_order = ( + self._read_excel(path, sheet_name='s')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 + t_order_group_function_order = ( + self._read_excel(path, sheet_name='t')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 + break else: raise FileNotFoundError(f'Can not find group_order.xlsx in {self.parameters_path}') return f_order_group_function_order, s_order_group_function_order, t_order_group_function_order @@ -78,4 +67,3 @@ def load_group_order(self): # # for i in d: # # print(i) # # print(d[i]) - diff --git a/groupy/gp_tool.py b/groupy/gp_tool.py index de412da..57069f6 100644 --- a/groupy/gp_tool.py +++ b/groupy/gp_tool.py @@ -1,5 +1,4 @@ import pandas as pd -from PIL.ImageSequence import Iterator class Tool: @@ -21,13 +20,14 @@ def load_smiles_iterator(smiles_file_path): """ print('reading input file...') if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) + with open(smiles_file_path, encoding='utf-8') as file: + smiles_iterator = list(file) elif smiles_file_path.endswith('.xlsx'): smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] elif smiles_file_path.endswith('.csv'): smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] else: - raise NotImplemented('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') + raise NotImplementedError('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') smiles_iterator = [i.strip() for i in smiles_iterator] return smiles_iterator diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e162140 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "Groupy" +version = "3.0.0" +description = "Groupy -- A Useful Tool for Molecular Analysis" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "Ruichen Liu", email = "1197748182@qq.com" }, +] +dependencies = [ + "ase", + "joblib", + "numpy", + "openpyxl", + "pandas", + "rdkit", + "tqdm", +] + +[project.optional-dependencies] +dev = [ + "build", + "pytest", +] + +[project.scripts] +Groupy = "groupy.groupy_main:main" + +[project.urls] +Homepage = "https://github.com/47-5/Groupy" + +[tool.setuptools.packages.find] +include = ["groupy*"] + +[tool.setuptools.package-data] +groupy = ["*.xlsx"] diff --git a/setup.py b/setup.py index 09c9f07..63294de 100644 --- a/setup.py +++ b/setup.py @@ -1,33 +1,9 @@ -from setuptools import setup, find_packages -from os.path import join +"""Compatibility shim for legacy tooling. +Project metadata lives in pyproject.toml. +""" -VERSION = '3.0.0' -DESCRIPTION = 'Groupy -- A Useful Tool for Molecular Analysis' +from setuptools import setup -setup( - name="Groupy", - version=VERSION, - author="Ruichen Liu", - author_email="1197748182@qq.com", - description=DESCRIPTION, - url='https://github.com/47-5/Groupy', - packages=find_packages(), - - install_requires=['rdkit', 'pandas', 'numpy', 'tqdm', 'ase', 'joblib', 'openpyxl'], - python_requires='>3.5', - - data_files=[(join('.', 'groupy_internal_data'), [join('.', 'groupy', 'group_contribution_parameters.xlsx'), - join('.', 'groupy', 'group_order.xlsx') - ])], - - - entry_points={ - 'console_scripts': [ - 'Groupy = groupy.groupy_main:main' - ] - }, - scripts=['groupy/groupy_main.py'], - -) \ No newline at end of file +setup() diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..c1cce5b --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,86 @@ +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import pandas as pd + +from groupy.gp_calculator import Calculator +from groupy.gp_counter import Counter +from groupy.gp_loader import Loader +from groupy.gp_tool import Tool + + +class LoaderSmokeTests(unittest.TestCase): + def test_loader_reads_bundled_data(self): + loader = Loader() + + parameters = loader.load_parameters() + group_orders = loader.load_group_order() + + self.assertEqual(len(parameters), 425) + self.assertEqual(group_orders[0][:3], [104, 105, 93]) + self.assertTrue(all(group_orders)) + + +class CoreChemistrySmokeTests(unittest.TestCase): + def test_counter_counts_cyclopentane(self): + result = Counter().count_a_mol("C1CCCC1", clear_mode=True, add_smiles=True) + + self.assertEqual(result, {"f_168": 5, "smiles": "C1CCCC1"}) + + def test_calculator_calculates_cyclopentane(self): + result = Calculator().calculate_a_mol("C1CCCC1") + + self.assertEqual(result["smiles"], "C1CCCC1") + self.assertAlmostEqual(result["molar_mass"], 70.135) + self.assertAlmostEqual(result["Tb/K"], 308.65) + self.assertAlmostEqual(result["Pc/bar"], 42.659) + self.assertEqual(result["note"], "C1CCCC1 at 298K") + + +class SmilesFileSmokeTests(unittest.TestCase): + def test_load_smiles_iterator_from_txt_csv_and_xlsx(self): + expected = ["C1CCCC1", "CCO"] + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + txt_path = tmp_path / "smiles.txt" + csv_path = tmp_path / "smiles.csv" + xlsx_path = tmp_path / "smiles.xlsx" + + txt_path.write_text("C1CCCC1\nCCO\n", encoding="utf-8") + pd.DataFrame({"smiles": expected}).to_csv(csv_path, index=False) + pd.DataFrame({"smiles": expected}).to_excel(xlsx_path, index=False) + + self.assertEqual(Tool.load_smiles_iterator(str(txt_path)), expected) + self.assertEqual(Tool.load_smiles_iterator(str(csv_path)), expected) + self.assertEqual(Tool.load_smiles_iterator(str(xlsx_path)), expected) + + +class CliSmokeTests(unittest.TestCase): + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by the legacy interactive CLI") + def test_legacy_cli_can_start_and_exit(self): + command = [ + sys.executable, + "-c", + "from groupy.groupy_main import main; main()", + ] + + completed = subprocess.run( + command, + input="q\n", + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("exit Groupy", completed.stdout) + + +if __name__ == "__main__": + unittest.main() From d95a1696cee65efedc4868ab3393c868b158584c Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 21:56:11 +0800 Subject: [PATCH 02/29] Add modern count CLI --- README.md | 4 ++ REFACTORING_PLAN.md | 56 +++++++++++++++++++++++--- groupy/cli.py | 96 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_smoke.py | 71 ++++++++++++++++++++++++++++++++- 5 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 groupy/cli.py diff --git a/README.md b/README.md index fca1940..81fabf1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ Install for development: Then one can enter `Groupy` in terminal to start Groupy. +For non-interactive use, one can run commands such as: + +`Groupy count --smiles C1CCCC1` + ### Manual and Documention The user manual is in the manual folder, and the API documentation can be found in the doc folder. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index ca3a805..32f4717 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -9,9 +9,31 @@ This document captures the agreed refactoring direction for Groupy. The guiding - Make local development work with `python -m pip install -e .`. - Keep existing scientific behavior stable while changing project structure. - Separate library APIs from command-line interaction. +- Prepare for a desktop GUI that ordinary users can launch by double-clicking. - Improve maintainability without rewriting the chemistry logic all at once. - Build a minimal regression test suite before deeper refactors. +## GUI Target + +The final user-facing application should be a desktop GUI, not a web dashboard. The preferred route is PySide6/Qt because it supports a native-feeling Windows application and can be packaged into a double-clickable executable. + +Recommended GUI scope: + +- Single-molecule property calculation from SMILES. +- Single-molecule group counting from SMILES. +- Batch calculation from `.txt`, `.csv`, or `.xlsx`. +- Batch group counting from `.txt`, `.csv`, or `.xlsx`. +- CSV export for tabular results. +- Clear dependency warnings for optional conversion/viewer features. + +Packaging target: + +- Windows executable or application folder built with PyInstaller or Nuitka. +- No requirement for end users to open a terminal. +- OpenBabel-dependent features should be optional or packaged only after dependency behavior is well understood. + +The GUI should call stable library APIs, not the legacy `input()` menu. + ## Phase 1: Packaging And Repository Hygiene - [x] Keep `pyproject.toml` as the canonical packaging metadata. @@ -51,14 +73,21 @@ python -W error::ResourceWarning -m unittest discover -s tests The current `groupy_main.py` is a fully interactive menu. Preserve it initially, but introduce a modern CLI layer around the existing API. -- [ ] Add `groupy/cli.py`. -- [ ] Move console entry point from `groupy.groupy_main:main` to the new CLI once ready. -- [ ] Preserve the old interactive menu as a subcommand or compatibility path. +- [x] Add `groupy/cli.py`. +- [x] Move console entry point from `groupy.groupy_main:main` to the new CLI once ready. +- [x] Preserve the old interactive menu as a subcommand or compatibility path. - [ ] Add scriptable commands such as: - - `groupy count --smiles C1CCCC1` + - [x] `Groupy count --smiles C1CCCC1` - `groupy calculate --input SMILES.txt --output result.csv` - `groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2` -- [ ] Keep command-line parsing separate from chemistry logic. +- [x] Keep command-line parsing separate from chemistry logic. + +Current CLI behavior: + +- `Groupy` starts the legacy interactive menu. +- `Groupy interactive` starts the legacy interactive menu explicitly. +- `Groupy count --smiles C1CCCC1` prints nonzero group counts as JSON. +- `Groupy count --smiles C1CCCC1 --output count.csv` writes one-row CSV output. ## Phase 4: File And Path Handling @@ -112,6 +141,21 @@ The current `groupy_main.py` is a fully interactive menu. Preserve it initially, - package build - [ ] Keep generated API documentation separate from source documentation. +## Phase 9: Desktop GUI And Application Packaging + +- [ ] Choose GUI framework, currently recommended: PySide6. +- [ ] Add a minimal `groupy/gui/` package. +- [ ] Build first GUI screen for: + - SMILES input + - property calculation + - group counting + - CSV export +- [ ] Add background worker handling so long calculations do not freeze the UI. +- [ ] Add GUI smoke tests where practical. +- [ ] Add packaging script for Windows executable builds. +- [ ] Test the packaged app on a clean Windows environment. +- [ ] Document limitations around OpenBabel and optional conversion features. + ## Current Known Review Findings - `Groupy.egg-info/`, `build/`, and `dist/` are tracked generated artifacts. @@ -128,3 +172,5 @@ Start with Phase 1 and Phase 2: 1. Remove generated artifacts from Git tracking. 2. Add the first `pytest` smoke tests. 3. Run editable install, compile, and tests after each small change. + +After Phase 2, prioritize Phase 3 API/CLI separation because the future GUI should call those same stable APIs. diff --git a/groupy/cli.py b/groupy/cli.py new file mode 100644 index 0000000..6fbec42 --- /dev/null +++ b/groupy/cli.py @@ -0,0 +1,96 @@ +"""Command-line interface for Groupy. + +The legacy interactive menu is still available, but new subcommands should call +the library API directly so they can also be reused by future GUI code. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Sequence + +import pandas as pd + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="Groupy", + description="Groupy command-line tools for molecular analysis.", + ) + subparsers = parser.add_subparsers(dest="command") + + subparsers.add_parser( + "interactive", + help="start the legacy interactive menu", + ) + + count_parser = subparsers.add_parser( + "count", + help="count molecular groups for a SMILES string", + ) + count_parser.add_argument( + "--smiles", + required=True, + help="SMILES string to analyze", + ) + count_parser.add_argument( + "--include-zero", + action="store_true", + help="include groups with zero counts in the output", + ) + count_parser.add_argument( + "--no-smiles", + action="store_true", + help="omit the input SMILES from the output", + ) + count_parser.add_argument( + "--output", + type=Path, + help="optional CSV path for saving the result", + ) + + return parser + + +def _run_legacy_interactive() -> int: + from groupy.groupy_main import main as legacy_main + + legacy_main() + return 0 + + +def _run_count(args: argparse.Namespace) -> int: + from groupy.gp_counter import Counter + + result = Counter().count_a_mol( + args.smiles, + clear_mode=not args.include_zero, + add_smiles=not args.no_smiles, + ) + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame([result]).to_csv(args.output, index=False) + else: + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command is None or args.command == "interactive": + return _run_legacy_interactive() + if args.command == "count": + return _run_count(args) + + parser.error(f"Unsupported command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index e162140..5b49bfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dev = [ ] [project.scripts] -Groupy = "groupy.groupy_main:main" +Groupy = "groupy.cli:main" [project.urls] Homepage = "https://github.com/47-5/Groupy" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c1cce5b..ec9012a 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,4 +1,5 @@ import importlib.util +import json import subprocess import sys import tempfile @@ -61,12 +62,78 @@ def test_load_smiles_iterator_from_txt_csv_and_xlsx(self): class CliSmokeTests(unittest.TestCase): + def test_cli_import_does_not_load_openbabel_conversion_stack(self): + command = [ + sys.executable, + "-c", + "import sys; import groupy.cli; print('groupy.gp_convertor' in sys.modules)", + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stdout.strip(), "False") + + def test_count_cli_outputs_json(self): + command = [ + sys.executable, + "-m", + "groupy.cli", + "count", + "--smiles", + "C1CCCC1", + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(json.loads(completed.stdout), {"f_168": 5, "smiles": "C1CCCC1"}) + + def test_count_cli_writes_csv(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "count.csv" + command = [ + sys.executable, + "-m", + "groupy.cli", + "count", + "--smiles", + "C1CCCC1", + "--output", + str(output_path), + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + result = pd.read_csv(output_path) + self.assertEqual(result.to_dict(orient="records"), [{"f_168": 5, "smiles": "C1CCCC1"}]) + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by the legacy interactive CLI") def test_legacy_cli_can_start_and_exit(self): command = [ sys.executable, - "-c", - "from groupy.groupy_main import main; main()", + "-m", + "groupy.cli", + "interactive", ] completed = subprocess.run( From a022adef08ce90c57cf53a88acb6e985e0261b79 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:02:06 +0800 Subject: [PATCH 03/29] Add reusable API and calculate CLI --- README.md | 2 + REFACTORING_PLAN.md | 6 ++- groupy/__init__.py | 2 + groupy/api.py | 113 ++++++++++++++++++++++++++++++++++++++++++++ groupy/cli.py | 104 ++++++++++++++++++++++++++++++++++------ tests/test_smoke.py | 60 +++++++++++++++++++++++ 6 files changed, 272 insertions(+), 15 deletions(-) create mode 100644 groupy/api.py diff --git a/README.md b/README.md index 81fabf1..789200d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ For non-interactive use, one can run commands such as: `Groupy count --smiles C1CCCC1` +`Groupy calculate --smiles C1CCCC1` + ### Manual and Documention The user manual is in the manual folder, and the API documentation can be found in the doc folder. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 32f4717..5636c5b 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -74,11 +74,13 @@ python -W error::ResourceWarning -m unittest discover -s tests The current `groupy_main.py` is a fully interactive menu. Preserve it initially, but introduce a modern CLI layer around the existing API. - [x] Add `groupy/cli.py`. +- [x] Add `groupy/api.py` for non-interactive workflows shared by CLI and future GUI code. - [x] Move console entry point from `groupy.groupy_main:main` to the new CLI once ready. - [x] Preserve the old interactive menu as a subcommand or compatibility path. - [ ] Add scriptable commands such as: - [x] `Groupy count --smiles C1CCCC1` - - `groupy calculate --input SMILES.txt --output result.csv` + - [x] `Groupy calculate --smiles C1CCCC1` + - [x] `Groupy calculate --input SMILES.txt --output result.csv` - `groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2` - [x] Keep command-line parsing separate from chemistry logic. @@ -88,6 +90,8 @@ Current CLI behavior: - `Groupy interactive` starts the legacy interactive menu explicitly. - `Groupy count --smiles C1CCCC1` prints nonzero group counts as JSON. - `Groupy count --smiles C1CCCC1 --output count.csv` writes one-row CSV output. +- `Groupy calculate --smiles C1CCCC1` prints calculated properties as JSON. +- `Groupy calculate --input SMILES.txt --output calculate.csv` writes batch calculated properties to CSV. ## Phase 4: File And Path Handling diff --git a/groupy/__init__.py b/groupy/__init__.py index 9e4812e..0910631 100644 --- a/groupy/__init__.py +++ b/groupy/__init__.py @@ -3,6 +3,8 @@ __version__ = "3.0.0" __all__ = [ + "api", + "cli", "gp_loader", "gp_tool", "gp_viewer", diff --git a/groupy/api.py b/groupy/api.py new file mode 100644 index 0000000..e80c3a8 --- /dev/null +++ b/groupy/api.py @@ -0,0 +1,113 @@ +"""Programmatic API helpers for non-interactive Groupy workflows.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +import pandas as pd + +from groupy.gp_calculator import Calculator +from groupy.gp_counter import Counter + + +def load_smiles_file(path: str | Path) -> list[str]: + """Load SMILES strings from a txt, csv, or xlsx file.""" + input_path = Path(path) + suffix = input_path.suffix.lower() + + if suffix == ".txt": + with input_path.open(encoding="utf-8") as file: + smiles = [line.strip() for line in file] + elif suffix == ".csv": + smiles = _load_smiles_column(pd.read_csv(input_path), input_path) + elif suffix == ".xlsx": + smiles = _load_smiles_column(pd.read_excel(input_path), input_path) + else: + raise ValueError("Input file must use .txt, .csv, or .xlsx format.") + + return [item for item in smiles if item] + + +def count_smiles( + smiles: str, + *, + include_zero: bool = False, + include_smiles: bool = True, + counter: Counter | None = None, +) -> dict[str, Any]: + """Count molecular groups for one SMILES string.""" + active_counter = counter or Counter() + return active_counter.count_a_mol( + smiles, + clear_mode=not include_zero, + add_smiles=include_smiles, + ) + + +def count_many_smiles( + smiles_values: Iterable[str], + *, + include_zero: bool = False, + include_smiles: bool = True, +) -> list[dict[str, Any]]: + """Count molecular groups for many SMILES strings.""" + counter = Counter() + return [ + count_smiles( + smiles, + include_zero=include_zero, + include_smiles=include_smiles, + counter=counter, + ) + for smiles in smiles_values + ] + + +def calculate_smiles( + smiles: str, + *, + check_hydrocarbon: bool = True, + parameter_type: str = "step_wise", + calculator: Calculator | None = None, +) -> dict[str, Any]: + """Calculate properties for one SMILES string.""" + active_calculator = calculator or Calculator() + return active_calculator.calculate_a_mol( + smiles, + check_hydrocarbon=check_hydrocarbon, + parameter_type=parameter_type, + ) + + +def calculate_many_smiles( + smiles_values: Iterable[str], + *, + check_hydrocarbon: bool = True, + parameter_type: str = "step_wise", +) -> list[dict[str, Any]]: + """Calculate properties for many SMILES strings.""" + calculator = Calculator() + return [ + calculate_smiles( + smiles, + check_hydrocarbon=check_hydrocarbon, + parameter_type=parameter_type, + calculator=calculator, + ) + for smiles in smiles_values + ] + + +def write_records_csv(records: list[dict[str, Any]], path: str | Path) -> None: + """Write result records to a CSV file.""" + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame(records).to_csv(output_path, index=False) + + +def _load_smiles_column(dataframe: pd.DataFrame, path: Path) -> list[str]: + if "smiles" not in dataframe.columns: + raise ValueError(f"{path} must contain a 'smiles' column.") + return [str(value).strip() for value in dataframe["smiles"].dropna()] diff --git a/groupy/cli.py b/groupy/cli.py index 6fbec42..8924a0f 100644 --- a/groupy/cli.py +++ b/groupy/cli.py @@ -11,8 +11,6 @@ from pathlib import Path from typing import Sequence -import pandas as pd - def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( @@ -28,13 +26,18 @@ def build_parser() -> argparse.ArgumentParser: count_parser = subparsers.add_parser( "count", - help="count molecular groups for a SMILES string", + help="count molecular groups", ) - count_parser.add_argument( + count_input = count_parser.add_mutually_exclusive_group(required=True) + count_input.add_argument( "--smiles", - required=True, help="SMILES string to analyze", ) + count_input.add_argument( + "--input", + type=Path, + help="txt, csv, or xlsx file containing SMILES values", + ) count_parser.add_argument( "--include-zero", action="store_true", @@ -51,6 +54,37 @@ def build_parser() -> argparse.ArgumentParser: help="optional CSV path for saving the result", ) + calculate_parser = subparsers.add_parser( + "calculate", + help="calculate molecular properties", + ) + calculate_input = calculate_parser.add_mutually_exclusive_group(required=True) + calculate_input.add_argument( + "--smiles", + help="SMILES string to analyze", + ) + calculate_input.add_argument( + "--input", + type=Path, + help="txt, csv, or xlsx file containing SMILES values", + ) + calculate_parser.add_argument( + "--parameter-type", + choices=["step_wise", "simultaneous"], + default="step_wise", + help="group contribution parameter type", + ) + calculate_parser.add_argument( + "--no-check-hydrocarbon", + action="store_true", + help="calculate combustion-related properties without hydrocarbon filtering", + ) + calculate_parser.add_argument( + "--output", + type=Path, + help="optional CSV path for saving the result", + ) + return parser @@ -62,23 +96,63 @@ def _run_legacy_interactive() -> int: def _run_count(args: argparse.Namespace) -> int: - from groupy.gp_counter import Counter + from groupy.api import count_many_smiles, count_smiles, load_smiles_file, write_records_csv + + if args.smiles: + records = [ + count_smiles( + args.smiles, + include_zero=args.include_zero, + include_smiles=not args.no_smiles, + ) + ] + else: + records = count_many_smiles( + load_smiles_file(args.input), + include_zero=args.include_zero, + include_smiles=not args.no_smiles, + ) - result = Counter().count_a_mol( - args.smiles, - clear_mode=not args.include_zero, - add_smiles=not args.no_smiles, - ) + if args.output: + write_records_csv(records, args.output) + else: + print(_format_json_output(records, single=bool(args.smiles))) + + return 0 + + +def _run_calculate(args: argparse.Namespace) -> int: + from groupy.api import calculate_many_smiles, calculate_smiles, load_smiles_file, write_records_csv + + check_hydrocarbon = not args.no_check_hydrocarbon + if args.smiles: + records = [ + calculate_smiles( + args.smiles, + check_hydrocarbon=check_hydrocarbon, + parameter_type=args.parameter_type, + ) + ] + else: + records = calculate_many_smiles( + load_smiles_file(args.input), + check_hydrocarbon=check_hydrocarbon, + parameter_type=args.parameter_type, + ) if args.output: - args.output.parent.mkdir(parents=True, exist_ok=True) - pd.DataFrame([result]).to_csv(args.output, index=False) + write_records_csv(records, args.output) else: - print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + print(_format_json_output(records, single=bool(args.smiles))) return 0 +def _format_json_output(records: list[dict], *, single: bool) -> str: + payload = records[0] if single else records + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) @@ -87,6 +161,8 @@ def main(argv: Sequence[str] | None = None) -> int: return _run_legacy_interactive() if args.command == "count": return _run_count(args) + if args.command == "calculate": + return _run_calculate(args) parser.error(f"Unsupported command: {args.command}") return 2 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index ec9012a..d2ec535 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -8,6 +8,7 @@ import pandas as pd +from groupy.api import load_smiles_file from groupy.gp_calculator import Calculator from groupy.gp_counter import Counter from groupy.gp_loader import Loader @@ -59,6 +60,9 @@ def test_load_smiles_iterator_from_txt_csv_and_xlsx(self): self.assertEqual(Tool.load_smiles_iterator(str(txt_path)), expected) self.assertEqual(Tool.load_smiles_iterator(str(csv_path)), expected) self.assertEqual(Tool.load_smiles_iterator(str(xlsx_path)), expected) + self.assertEqual(load_smiles_file(txt_path), expected) + self.assertEqual(load_smiles_file(csv_path), expected) + self.assertEqual(load_smiles_file(xlsx_path), expected) class CliSmokeTests(unittest.TestCase): @@ -127,6 +131,62 @@ def test_count_cli_writes_csv(self): result = pd.read_csv(output_path) self.assertEqual(result.to_dict(orient="records"), [{"f_168": 5, "smiles": "C1CCCC1"}]) + def test_calculate_cli_outputs_json(self): + command = [ + sys.executable, + "-m", + "groupy.cli", + "calculate", + "--smiles", + "C1CCCC1", + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + result = json.loads(completed.stdout) + self.assertEqual(result["smiles"], "C1CCCC1") + self.assertAlmostEqual(result["molar_mass"], 70.135) + self.assertAlmostEqual(result["Tb/K"], 308.65) + self.assertEqual(result["note"], "C1CCCC1 at 298K") + + def test_calculate_cli_writes_csv_from_input_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + output_path = tmp_path / "calculate.csv" + input_path.write_text("C1CCCC1\nCCO\n", encoding="utf-8") + + command = [ + sys.executable, + "-m", + "groupy.cli", + "calculate", + "--input", + str(input_path), + "--output", + str(output_path), + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=30, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + result = pd.read_csv(output_path) + self.assertEqual(result["smiles"].tolist(), ["C1CCCC1", "CCO"]) + self.assertIn("molar_mass", result.columns) + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by the legacy interactive CLI") def test_legacy_cli_can_start_and_exit(self): command = [ From 720677005a068e48a0664cc2cc2ceb26353b762c Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:05:17 +0800 Subject: [PATCH 04/29] Centralize SMILES file loading --- REFACTORING_PLAN.md | 12 +++++++++--- groupy/__init__.py | 1 + groupy/api.py | 34 +--------------------------------- groupy/gp_calculator.py | 23 ++++++++--------------- groupy/gp_counter.py | 27 ++++++++++----------------- groupy/gp_tool.py | 17 ++++++----------- groupy/io.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/test_smoke.py | 41 ++++++++++++++++++++++++++++++++++++++++- 8 files changed, 114 insertions(+), 80 deletions(-) create mode 100644 groupy/io.py diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 5636c5b..1d2551b 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -96,12 +96,18 @@ Current CLI behavior: ## Phase 4: File And Path Handling - [ ] Replace scattered `os.path` logic with `pathlib.Path` where practical. -- [ ] Centralize SMILES file loading into one helper. -- [ ] Replace `list(open(...))` with context-managed file reads. -- [ ] Use explicit encodings for text files. +- [x] Centralize SMILES file loading into one helper. +- [x] Replace `list(open(...))` with context-managed file reads for SMILES input. +- [x] Use explicit encodings for text SMILES files. - [ ] Avoid writing implicit side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` to the current working directory unless requested. - [ ] Make batch output paths predictable and configurable. +Current file handling status: + +- `groupy.io.load_smiles_file()` is the shared loader for `.txt`, `.csv`, and `.xlsx` SMILES inputs. +- `groupy.api`, `groupy.gp_tool.Tool`, `Calculator.calculate_mols*`, and `Counter.count_mols*` use the shared loader. +- Existing side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` still need cleanup in later steps. + ## Phase 5: Exceptions, Logging, And Error Reporting - [ ] Replace bare `except:` blocks with specific exceptions. diff --git a/groupy/__init__.py b/groupy/__init__.py index 0910631..ee4f004 100644 --- a/groupy/__init__.py +++ b/groupy/__init__.py @@ -5,6 +5,7 @@ __all__ = [ "api", "cli", + "io", "gp_loader", "gp_tool", "gp_viewer", diff --git a/groupy/api.py b/groupy/api.py index e80c3a8..62e9e33 100644 --- a/groupy/api.py +++ b/groupy/api.py @@ -3,31 +3,11 @@ from __future__ import annotations from collections.abc import Iterable -from pathlib import Path from typing import Any -import pandas as pd - from groupy.gp_calculator import Calculator from groupy.gp_counter import Counter - - -def load_smiles_file(path: str | Path) -> list[str]: - """Load SMILES strings from a txt, csv, or xlsx file.""" - input_path = Path(path) - suffix = input_path.suffix.lower() - - if suffix == ".txt": - with input_path.open(encoding="utf-8") as file: - smiles = [line.strip() for line in file] - elif suffix == ".csv": - smiles = _load_smiles_column(pd.read_csv(input_path), input_path) - elif suffix == ".xlsx": - smiles = _load_smiles_column(pd.read_excel(input_path), input_path) - else: - raise ValueError("Input file must use .txt, .csv, or .xlsx format.") - - return [item for item in smiles if item] +from groupy.io import load_smiles_file, write_records_csv def count_smiles( @@ -99,15 +79,3 @@ def calculate_many_smiles( for smiles in smiles_values ] - -def write_records_csv(records: list[dict[str, Any]], path: str | Path) -> None: - """Write result records to a CSV file.""" - output_path = Path(path) - output_path.parent.mkdir(parents=True, exist_ok=True) - pd.DataFrame(records).to_csv(output_path, index=False) - - -def _load_smiles_column(dataframe: pd.DataFrame, path: Path) -> list[str]: - if "smiles" not in dataframe.columns: - raise ValueError(f"{path} must contain a 'smiles' column.") - return [str(value).strip() for value in dataframe["smiles"].dropna()] diff --git a/groupy/gp_calculator.py b/groupy/gp_calculator.py index fef4034..7afb5b5 100644 --- a/groupy/gp_calculator.py +++ b/groupy/gp_calculator.py @@ -6,6 +6,7 @@ from groupy.gp_loader import Loader from groupy.gp_counter import Counter +from groupy.io import load_smiles_file class Calculator: @@ -438,13 +439,9 @@ def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.cs :return: instance of pandas.DataFrame """ print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: + try: + smiles_iterator = load_smiles_file(smiles_file_path) + except ValueError: print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') return None mol_number = len(smiles_iterator) @@ -480,13 +477,9 @@ def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_resul :return: instance of pandas.DataFrame """ print('reading input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: + try: + smiles_iterator = load_smiles_file(smiles_file_path) + except ValueError: print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') return None mol_number = len(smiles_iterator) @@ -508,4 +501,4 @@ def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_resul # c = Calculator() # c.calculate_mols_mpi(smiles_file_path='../gp_3x_test_mol/SMILES.txt', n_jobs=4, check_hydrocarbon=False) # t2 = time.time() -# print(t2 - t1) \ No newline at end of file +# print(t2 - t1) diff --git a/groupy/gp_counter.py b/groupy/gp_counter.py index b1249c5..0187ed0 100644 --- a/groupy/gp_counter.py +++ b/groupy/gp_counter.py @@ -4,6 +4,7 @@ from joblib import Parallel, delayed from groupy.gp_loader import Loader +from groupy.io import load_smiles_file __all__ = ["Counter", ] @@ -3266,15 +3267,11 @@ def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv' :return: pandas Dataframe. A dictionary that stores the results. """ print('reading the input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: + try: + smiles_iterator = load_smiles_file(smiles_file_path) + except ValueError as exc: raise NotImplementedError( - 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') + 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') from exc mol_number = len(smiles_iterator) print('Done, totally detected {} molecules, start counting...'.format(mol_number)) @@ -3300,15 +3297,11 @@ def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result. :return: pandas Dataframe. A dictionary that stores the results. """ print('reading the input file...') - if smiles_file_path.endswith('.txt'): - smiles_iterator = list(open(smiles_file_path)) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: + try: + smiles_iterator = load_smiles_file(smiles_file_path) + except ValueError as exc: raise NotImplementedError( - 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') + 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') from exc mol_number = len(smiles_iterator) print('Done, totally detected {} molecules, start counting...'.format(mol_number)) @@ -3415,4 +3408,4 @@ def count_3rd_order_groups(self, mol): # # # # # # t2 = time.time() # # # print(t2 - t1) -# # print(os.path.exists(r'C:\Users\tjulrc\Desktop\Groupy_test\groupy\gp_3x_internal_data\group_order.xlsx')) \ No newline at end of file +# # print(os.path.exists(r'C:\Users\tjulrc\Desktop\Groupy_test\groupy\gp_3x_internal_data\group_order.xlsx')) diff --git a/groupy/gp_tool.py b/groupy/gp_tool.py index 57069f6..9e88187 100644 --- a/groupy/gp_tool.py +++ b/groupy/gp_tool.py @@ -1,5 +1,7 @@ import pandas as pd +from groupy.io import load_smiles_file + class Tool: """ @@ -19,17 +21,10 @@ def load_smiles_iterator(smiles_file_path): :return: Iterator. """ print('reading input file...') - if smiles_file_path.endswith('.txt'): - with open(smiles_file_path, encoding='utf-8') as file: - smiles_iterator = list(file) - elif smiles_file_path.endswith('.xlsx'): - smiles_iterator = pd.read_excel(smiles_file_path)['smiles'] - elif smiles_file_path.endswith('.csv'): - smiles_iterator = pd.read_csv(smiles_file_path)['smiles'] - else: - raise NotImplementedError('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') - smiles_iterator = [i.strip() for i in smiles_iterator] - return smiles_iterator + try: + return load_smiles_file(smiles_file_path) + except ValueError as exc: + raise NotImplementedError('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') from exc def export_a_dict(result_dict, export_path='result.csv'): diff --git a/groupy/io.py b/groupy/io.py new file mode 100644 index 0000000..b9212a2 --- /dev/null +++ b/groupy/io.py @@ -0,0 +1,39 @@ +"""Shared file I/O helpers for Groupy.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pandas as pd + + +def load_smiles_file(path: str | Path) -> list[str]: + """Load SMILES strings from a txt, csv, or xlsx file.""" + input_path = Path(path) + suffix = input_path.suffix.lower() + + if suffix == ".txt": + with input_path.open(encoding="utf-8") as file: + smiles = [line.strip() for line in file] + elif suffix == ".csv": + smiles = _load_smiles_column(pd.read_csv(input_path), input_path) + elif suffix == ".xlsx": + smiles = _load_smiles_column(pd.read_excel(input_path), input_path) + else: + raise ValueError("Input file must use .txt, .csv, or .xlsx format.") + + return [item for item in smiles if item] + + +def write_records_csv(records: list[dict[str, Any]], path: str | Path) -> None: + """Write result records to a CSV file.""" + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + pd.DataFrame(records).to_csv(output_path, index=False) + + +def _load_smiles_column(dataframe: pd.DataFrame, path: Path) -> list[str]: + if "smiles" not in dataframe.columns: + raise ValueError(f"{path} must contain a 'smiles' column.") + return [str(value).strip() for value in dataframe["smiles"].dropna()] diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d2ec535..0738794 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,5 +1,6 @@ import importlib.util import json +import os import subprocess import sys import tempfile @@ -8,11 +9,11 @@ import pandas as pd -from groupy.api import load_smiles_file from groupy.gp_calculator import Calculator from groupy.gp_counter import Counter from groupy.gp_loader import Loader from groupy.gp_tool import Tool +from groupy.io import load_smiles_file class LoaderSmokeTests(unittest.TestCase): @@ -42,6 +43,44 @@ def test_calculator_calculates_cyclopentane(self): self.assertAlmostEqual(result["Pc/bar"], 42.659) self.assertEqual(result["note"], "C1CCCC1 at 298K") + def test_counter_batch_uses_shared_smiles_loader(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + output_path = tmp_path / "count.csv" + input_path.write_text("C1CCCC1\n", encoding="utf-8") + + result = Counter().count_mols( + str(input_path), + count_result_file_path=str(output_path), + add_smiles=True, + ) + + self.assertEqual(result.loc[0, "smiles"], "C1CCCC1") + self.assertEqual(result.loc[0, "f_168"], 5) + self.assertTrue(output_path.exists()) + + def test_calculator_batch_uses_shared_smiles_loader(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + output_path = tmp_path / "calculate.csv" + input_path.write_text("C1CCCC1\n", encoding="utf-8") + + original_cwd = Path.cwd() + os.chdir(tmp_path) + try: + result = Calculator().calculate_mols( + str(input_path), + properties_file_path=str(output_path), + ) + finally: + os.chdir(original_cwd) + + self.assertEqual(result.loc[0, "smiles"], "C1CCCC1") + self.assertAlmostEqual(result.loc[0, "molar_mass"], 70.135) + self.assertTrue(output_path.exists()) + class SmilesFileSmokeTests(unittest.TestCase): def test_load_smiles_iterator_from_txt_csv_and_xlsx(self): From 6de7d53f382f3aedc974a3f410e15e44f790819d Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:14:09 +0800 Subject: [PATCH 05/29] Make calculator error output explicit --- REFACTORING_PLAN.md | 6 +++++- groupy/gp_calculator.py | 13 +++++++------ groupy/io.py | 9 +++++++++ tests/test_smoke.py | 25 +++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 1d2551b..2f0815d 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -100,13 +100,17 @@ Current CLI behavior: - [x] Replace `list(open(...))` with context-managed file reads for SMILES input. - [x] Use explicit encodings for text SMILES files. - [ ] Avoid writing implicit side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` to the current working directory unless requested. + - [x] `Calculator.calculate_mols()` no longer writes `error.txt` unless `error_file_path` is provided. + - [ ] `Convertor` failure logs such as `xyz_fail.txt` and `xyz_succeed.txt`. + - [ ] `Generator` failure logs such as `gjf_fail.txt` and `gjf_succeed.txt`. - [ ] Make batch output paths predictable and configurable. Current file handling status: - `groupy.io.load_smiles_file()` is the shared loader for `.txt`, `.csv`, and `.xlsx` SMILES inputs. - `groupy.api`, `groupy.gp_tool.Tool`, `Calculator.calculate_mols*`, and `Counter.count_mols*` use the shared loader. -- Existing side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` still need cleanup in later steps. +- `Calculator.calculate_mols()` now supports an explicit `error_file_path` for failed SMILES output. +- Remaining side-effect files such as `xyz_fail.txt`, `xyz_succeed.txt`, `gjf_fail.txt`, and `gjf_succeed.txt` still need cleanup in later steps. ## Phase 5: Exceptions, Logging, And Error Reporting diff --git a/groupy/gp_calculator.py b/groupy/gp_calculator.py index 7afb5b5..df13ef5 100644 --- a/groupy/gp_calculator.py +++ b/groupy/gp_calculator.py @@ -6,7 +6,7 @@ from groupy.gp_loader import Loader from groupy.gp_counter import Counter -from groupy.io import load_smiles_file +from groupy.io import load_smiles_file, write_text_lines class Calculator: @@ -428,7 +428,8 @@ def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=Tru 'ISP': '?', 'note': 'There must be something wrong with this SMILES'} - def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.csv', check_hydrocarbon=True, parameter_type='step_wise'): + def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.csv', + check_hydrocarbon=True, parameter_type='step_wise', error_file_path=None): """ Calculating properties of a batch of molecules. :param smiles_file_path: path of the file(.txt, .xlsx, .csv) in which saved SMILES. @@ -436,6 +437,7 @@ def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.cs :param check_hydrocarbon: bool. Since Calculator.delta_Hc() was designed for hydrocarbon, if set to True, Calculator will check whether the molecule is hydrocarbon. If the molecule is not hydrocarbon, Calculator will not calculate delta_Hc, q and ISP. If set to False, Calculator will calculate these properties no matter whether the molecule is hydrocarbon. Default=True. + :param error_file_path: optional path for writing SMILES strings that failed during batch calculation. :return: instance of pandas.DataFrame """ print('reading input file...') @@ -452,15 +454,14 @@ def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.cs for i in tqdm(smiles_iterator): try: properties_dict_list.append(self.calculate_a_mol(i, check_hydrocarbon=check_hydrocarbon, parameter_type=parameter_type)) - except: + except Exception: error_smi.append(i) print('calculation completed!') print('start to export result to {} ...'.format(properties_file_path)) result = pd.DataFrame(properties_dict_list) result.to_csv(properties_file_path, index_label='index') - with open('error.txt', 'w') as f: - for i in error_smi: - f.write(i + '\n') + if error_file_path is not None: + write_text_lines(error_smi, error_file_path) print('Done!') return result diff --git a/groupy/io.py b/groupy/io.py index b9212a2..0a67a81 100644 --- a/groupy/io.py +++ b/groupy/io.py @@ -33,6 +33,15 @@ def write_records_csv(records: list[dict[str, Any]], path: str | Path) -> None: pd.DataFrame(records).to_csv(output_path, index=False) +def write_text_lines(lines: list[str], path: str | Path) -> None: + """Write one text item per line.""" + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8") as file: + for line in lines: + file.write(f"{line}\n") + + def _load_smiles_column(dataframe: pd.DataFrame, path: Path) -> list[str]: if "smiles" not in dataframe.columns: raise ValueError(f"{path} must contain a 'smiles' column.") diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0738794..b6bd97e 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -80,6 +80,31 @@ def test_calculator_batch_uses_shared_smiles_loader(self): self.assertEqual(result.loc[0, "smiles"], "C1CCCC1") self.assertAlmostEqual(result.loc[0, "molar_mass"], 70.135) self.assertTrue(output_path.exists()) + self.assertFalse((tmp_path / "error.txt").exists()) + + def test_calculator_batch_writes_error_file_only_when_requested(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + output_path = tmp_path / "calculate.csv" + error_path = tmp_path / "errors.txt" + input_path.write_text("C1CCCC1\n", encoding="utf-8") + + calculator = Calculator() + + def fail_calculation(*args, **kwargs): + raise ValueError("forced failure") + + calculator.calculate_a_mol = fail_calculation + result = calculator.calculate_mols( + str(input_path), + properties_file_path=str(output_path), + error_file_path=str(error_path), + ) + + self.assertTrue(result.empty) + self.assertEqual(error_path.read_text(encoding="utf-8"), "C1CCCC1\n") + self.assertTrue(output_path.exists()) class SmilesFileSmokeTests(unittest.TestCase): From e48c108d7382477bfdf60235e02487310fe525ac Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:17:01 +0800 Subject: [PATCH 06/29] Make xyz batch logs explicit --- REFACTORING_PLAN.md | 5 ++-- groupy/gp_convertor.py | 17 ++++++------ tests/test_smoke.py | 63 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 2f0815d..69531db 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -101,7 +101,7 @@ Current CLI behavior: - [x] Use explicit encodings for text SMILES files. - [ ] Avoid writing implicit side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` to the current working directory unless requested. - [x] `Calculator.calculate_mols()` no longer writes `error.txt` unless `error_file_path` is provided. - - [ ] `Convertor` failure logs such as `xyz_fail.txt` and `xyz_succeed.txt`. + - [x] `Convertor.batch_smi_to_xyz()` no longer writes `xyz_fail.txt` or `xyz_succeed.txt` unless explicit paths are provided. - [ ] `Generator` failure logs such as `gjf_fail.txt` and `gjf_succeed.txt`. - [ ] Make batch output paths predictable and configurable. @@ -110,7 +110,8 @@ Current file handling status: - `groupy.io.load_smiles_file()` is the shared loader for `.txt`, `.csv`, and `.xlsx` SMILES inputs. - `groupy.api`, `groupy.gp_tool.Tool`, `Calculator.calculate_mols*`, and `Counter.count_mols*` use the shared loader. - `Calculator.calculate_mols()` now supports an explicit `error_file_path` for failed SMILES output. -- Remaining side-effect files such as `xyz_fail.txt`, `xyz_succeed.txt`, `gjf_fail.txt`, and `gjf_succeed.txt` still need cleanup in later steps. +- `Convertor.batch_smi_to_xyz()` now supports explicit `fail_file_path` and `succeed_file_path` outputs. +- Remaining side-effect files such as `gjf_fail.txt` and `gjf_succeed.txt` still need cleanup in later steps. ## Phase 5: Exceptions, Logging, And Error Reporting diff --git a/groupy/gp_convertor.py b/groupy/gp_convertor.py index bff4457..7b162ca 100644 --- a/groupy/gp_convertor.py +++ b/groupy/gp_convertor.py @@ -8,6 +8,7 @@ from pprint import pprint from groupy.gp_tool import Tool +from groupy.io import write_text_lines class Convertor: @@ -76,11 +77,13 @@ def smi_to_xyz(smi, xyz_path=None): file.write(i.split()[2] + '\n') return True - def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path): + def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, fail_file_path=None, succeed_file_path=None): """ Converting a batch of SMILES to xyz files. :param smiles_file_path: str. Path of the file in which saved SMILES. :param xyz_root_path: str. The folder path where all generated xyz files are saved. + :param fail_file_path: optional path for writing SMILES strings that failed to generate xyz files. + :param succeed_file_path: optional path for writing SMILES strings that successfully generated xyz files. :return: None. """ smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) @@ -107,12 +110,10 @@ def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path): else: succeed.append(smi) - with open('xyz_fail.txt', 'w') as f: - for i in fail: - f.write(i + '\n') - with open('xyz_succeed.txt', 'w') as f: - for i in succeed: - f.write(i + '\n') + if fail_file_path is not None: + write_text_lines(fail, fail_file_path) + if succeed_file_path is not None: + write_text_lines(succeed, succeed_file_path) if len(fail) == 0: print('done! all .xyz files has been saved in {}'.format(xyz_root_path)) @@ -409,4 +410,4 @@ def plot_supported_format(self): # # c.batch_file_to_smi(in_format='mol2', in_root_path=os.path.join('gp_3x_test_mol', 'test_mol')) # x = c.batch_file_to_smi_mpi(in_format='mol', in_root_path='./test', n_jobs=4, batch_size='auto') # t2 = time.time() -# print(t2 - t1) \ No newline at end of file +# print(t2 - t1) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index b6bd97e..365a26f 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -129,6 +129,69 @@ def test_load_smiles_iterator_from_txt_csv_and_xlsx(self): self.assertEqual(load_smiles_file(xlsx_path), expected) +class ConvertorSmokeTests(unittest.TestCase): + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_convertor") + def test_batch_smi_to_xyz_does_not_write_logs_by_default(self): + from groupy.gp_convertor import Convertor + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + xyz_root = tmp_path / "xyz" + input_path.write_text("C1CCCC1\ninvalid\n", encoding="utf-8") + + convertor = Convertor() + + def fake_smi_to_xyz(smi, xyz_path=None): + if smi == "invalid": + return False + Path(xyz_path).write_text("fake xyz\n", encoding="utf-8") + return True + + convertor.smi_to_xyz = fake_smi_to_xyz + + original_cwd = Path.cwd() + os.chdir(tmp_path) + try: + convertor.batch_smi_to_xyz(str(input_path), str(xyz_root)) + finally: + os.chdir(original_cwd) + + self.assertFalse((tmp_path / "xyz_fail.txt").exists()) + self.assertFalse((tmp_path / "xyz_succeed.txt").exists()) + + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_convertor") + def test_batch_smi_to_xyz_writes_logs_when_requested(self): + from groupy.gp_convertor import Convertor + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + xyz_root = tmp_path / "xyz" + fail_path = tmp_path / "logs" / "xyz_fail.txt" + succeed_path = tmp_path / "logs" / "xyz_succeed.txt" + input_path.write_text("C1CCCC1\ninvalid\n", encoding="utf-8") + + convertor = Convertor() + + def fake_smi_to_xyz(smi, xyz_path=None): + if smi == "invalid": + return False + Path(xyz_path).write_text("fake xyz\n", encoding="utf-8") + return True + + convertor.smi_to_xyz = fake_smi_to_xyz + convertor.batch_smi_to_xyz( + str(input_path), + str(xyz_root), + fail_file_path=str(fail_path), + succeed_file_path=str(succeed_path), + ) + + self.assertEqual(fail_path.read_text(encoding="utf-8"), "invalid\n") + self.assertEqual(succeed_path.read_text(encoding="utf-8"), "C1CCCC1\n") + + class CliSmokeTests(unittest.TestCase): def test_cli_import_does_not_load_openbabel_conversion_stack(self): command = [ From ec18f34ca7688fd4e956356be7aeb0b99f8e9475 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:19:53 +0800 Subject: [PATCH 07/29] Make gjf batch logs explicit --- REFACTORING_PLAN.md | 4 +-- groupy/gp_generator.py | 15 +++++----- tests/test_smoke.py | 63 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 69531db..0de1761 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -102,7 +102,7 @@ Current CLI behavior: - [ ] Avoid writing implicit side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` to the current working directory unless requested. - [x] `Calculator.calculate_mols()` no longer writes `error.txt` unless `error_file_path` is provided. - [x] `Convertor.batch_smi_to_xyz()` no longer writes `xyz_fail.txt` or `xyz_succeed.txt` unless explicit paths are provided. - - [ ] `Generator` failure logs such as `gjf_fail.txt` and `gjf_succeed.txt`. + - [x] `Generator.batch_smi_to_gjf()` no longer writes `gjf_fail.txt` or `gjf_succeed.txt` unless explicit paths are provided. - [ ] Make batch output paths predictable and configurable. Current file handling status: @@ -111,7 +111,7 @@ Current file handling status: - `groupy.api`, `groupy.gp_tool.Tool`, `Calculator.calculate_mols*`, and `Counter.count_mols*` use the shared loader. - `Calculator.calculate_mols()` now supports an explicit `error_file_path` for failed SMILES output. - `Convertor.batch_smi_to_xyz()` now supports explicit `fail_file_path` and `succeed_file_path` outputs. -- Remaining side-effect files such as `gjf_fail.txt` and `gjf_succeed.txt` still need cleanup in later steps. +- `Generator.batch_smi_to_gjf()` now supports explicit `fail_file_path` and `succeed_file_path` outputs. ## Phase 5: Exceptions, Logging, And Error Reporting diff --git a/groupy/gp_generator.py b/groupy/gp_generator.py index 87d8fee..1d4475f 100644 --- a/groupy/gp_generator.py +++ b/groupy/gp_generator.py @@ -7,6 +7,7 @@ from groupy.gp_convertor import Convertor from groupy.gp_tool import Tool +from groupy.io import write_text_lines class Generator: @@ -130,6 +131,7 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, gaussian_keywords=None, charge_and_multiplicity=None, add_other_tasks=False, other_tasks: list = None, index_start=0, + fail_file_path=None, succeed_file_path=None, ): """ Generating some gjf files based on a file in which saved some SMILES. @@ -144,6 +146,8 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, :param add_other_tasks: bool. Whether to add other job into you gjf file. Default=False :param other_tasks: list. Jobs you want to add into you gjf file. Default = ['#p m062x/def2tzvp geom=check', '#p m062x/def2tzvp scrf=solvent=water geom=check',]. Note that this parameter will only be used if add_other_tasks=True. + :param fail_file_path: optional path for writing SMILES strings that failed to generate gjf files. + :param succeed_file_path: optional path for writing SMILES strings that successfully generated gjf files. :return: None """ smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) @@ -177,12 +181,10 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, else: succeed.append(smi) - with open('gjf_fail.txt', 'w') as f: - for i in fail: - f.write(i + '\n') - with open('gjf_succeed.txt', 'w') as f: - for i in succeed: - f.write(i + '\n') + if fail_file_path is not None: + write_text_lines(fail, fail_file_path) + if succeed_file_path is not None: + write_text_lines(succeed, succeed_file_path) if len(fail) == 0: print('done! all .gjf files has been saved in {}'.format(gjf_root_path)) else: @@ -293,4 +295,3 @@ def write_gjf_blank_line(gjf_path, blank_line_number=1): # # g.batch_smi_to_gjf_mpi(smiles_file_path='gp_3x_test_mol/3018_with_error_smiles.txt', gjf_root_path='./test_gjf', # # add_other_tasks=True, # # n_jobs=8, batch_size='auto') - diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 365a26f..d2ad3b5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -192,6 +192,69 @@ def fake_smi_to_xyz(smi, xyz_path=None): self.assertEqual(succeed_path.read_text(encoding="utf-8"), "C1CCCC1\n") +class GeneratorSmokeTests(unittest.TestCase): + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_generator") + def test_batch_smi_to_gjf_does_not_write_logs_by_default(self): + from groupy.gp_generator import Generator + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + gjf_root = tmp_path / "gjf" + input_path.write_text("C1CCCC1\ninvalid\n", encoding="utf-8") + + generator = Generator() + + def fake_smi_to_gjf(smi, **kwargs): + if smi == "invalid": + return False + Path(kwargs["gjf_path"]).write_text("fake gjf\n", encoding="utf-8") + return True + + generator.smi_to_gjf = fake_smi_to_gjf + + original_cwd = Path.cwd() + os.chdir(tmp_path) + try: + generator.batch_smi_to_gjf(str(input_path), str(gjf_root)) + finally: + os.chdir(original_cwd) + + self.assertFalse((tmp_path / "gjf_fail.txt").exists()) + self.assertFalse((tmp_path / "gjf_succeed.txt").exists()) + + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_generator") + def test_batch_smi_to_gjf_writes_logs_when_requested(self): + from groupy.gp_generator import Generator + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + gjf_root = tmp_path / "gjf" + fail_path = tmp_path / "logs" / "gjf_fail.txt" + succeed_path = tmp_path / "logs" / "gjf_succeed.txt" + input_path.write_text("C1CCCC1\ninvalid\n", encoding="utf-8") + + generator = Generator() + + def fake_smi_to_gjf(smi, **kwargs): + if smi == "invalid": + return False + Path(kwargs["gjf_path"]).write_text("fake gjf\n", encoding="utf-8") + return True + + generator.smi_to_gjf = fake_smi_to_gjf + generator.batch_smi_to_gjf( + str(input_path), + str(gjf_root), + fail_file_path=str(fail_path), + succeed_file_path=str(succeed_path), + ) + + self.assertEqual(fail_path.read_text(encoding="utf-8"), "invalid\n") + self.assertEqual(succeed_path.read_text(encoding="utf-8"), "C1CCCC1\n") + + class CliSmokeTests(unittest.TestCase): def test_cli_import_does_not_load_openbabel_conversion_stack(self): command = [ From 426b6db06526941f640e824540795e60e3891e93 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:23:02 +0800 Subject: [PATCH 08/29] Add parallel aliases for joblib APIs --- REFACTORING_PLAN.md | 13 ++++++- groupy/gp_calculator.py | 9 +++++ groupy/gp_convertor.py | 27 ++++++++++++++ groupy/gp_counter.py | 9 +++++ groupy/gp_generator.py | 9 +++++ tests/test_smoke.py | 82 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 2 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 0de1761..cacb4bb 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -137,8 +137,17 @@ Current file handling status: - [ ] Cache `Loader` results so Excel files are not repeatedly parsed. - [ ] Keep SMARTS/group-counting logic behaviorally unchanged until tests cover it better. -- [ ] Rename or alias `*_mpi` APIs to `*_parallel`, since they use `joblib`, not MPI. -- [ ] Preserve old method names during transition for backward compatibility. +- [x] Rename or alias `*_mpi` APIs to `*_parallel`, since they use `joblib`, not MPI. +- [x] Preserve old method names during transition for backward compatibility. + +Current parallel API status: + +- `Calculator.calculate_mols_parallel()` calls `calculate_mols_mpi()`. +- `Counter.count_mols_parallel()` calls `count_mols_mpi()`. +- `Convertor.batch_smi_to_xyz_parallel()` calls `batch_smi_to_xyz_mpi()`. +- `Convertor.batch_convert_file_type_parallel()` calls `batch_convert_file_type_mpi()`. +- `Convertor.batch_file_to_smi_parallel()` calls `batch_file_to_smi_mpi()`. +- `Generator.batch_smi_to_gjf_parallel()` calls `batch_smi_to_gjf_mpi()`. ## Phase 8: Documentation And CI diff --git a/groupy/gp_calculator.py b/groupy/gp_calculator.py index df13ef5..d419fcc 100644 --- a/groupy/gp_calculator.py +++ b/groupy/gp_calculator.py @@ -495,6 +495,15 @@ def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_resul print('Done!') return result + def calculate_mols_parallel(self, *args, **kwargs): + """ + Calculating properties of a batch of molecules with joblib parallelism. + + This is the preferred name for new code. It calls calculate_mols_mpi() + for backward compatibility with the original API. + """ + return self.calculate_mols_mpi(*args, **kwargs) + # if __name__ == '__main__': # import time diff --git a/groupy/gp_convertor.py b/groupy/gp_convertor.py index 7b162ca..d652f9b 100644 --- a/groupy/gp_convertor.py +++ b/groupy/gp_convertor.py @@ -148,6 +148,15 @@ def batch_smi_to_xyz_mpi(self, smiles_file_path, xyz_root_path, n_jobs=1, batch_ print('done! all .xyz files has been saved in {}'.format(xyz_root_path)) return result + def batch_smi_to_xyz_parallel(self, *args, **kwargs): + """ + Converting a batch of SMILES to xyz files with joblib parallelism. + + This is the preferred name for new code. It calls batch_smi_to_xyz_mpi() + for backward compatibility with the original API. + """ + return self.batch_smi_to_xyz_mpi(*args, **kwargs) + @staticmethod def convert_file_type(in_format, in_path, out_format, out_path=None): """ @@ -246,6 +255,15 @@ def batch_convert_file_type_mpi(self, in_format, in_root_path, out_format, out_r result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) return result + def batch_convert_file_type_parallel(self, *args, **kwargs): + """ + Converting format of a batch of files with joblib parallelism. + + This is the preferred name for new code. It calls batch_convert_file_type_mpi() + for backward compatibility with the original API. + """ + return self.batch_convert_file_type_mpi(*args, **kwargs) + @staticmethod def file_to_smi(file_path, format=None): """ @@ -337,6 +355,15 @@ def batch_file_to_smi_mpi(self, in_format, in_root_path, out_root_path=None, n_j f.write(i + '\n') return smi_list + def batch_file_to_smi_parallel(self, *args, **kwargs): + """ + Converting a batch of files into SMILES with joblib parallelism. + + This is the preferred name for new code. It calls batch_file_to_smi_mpi() + for backward compatibility with the original API. + """ + return self.batch_file_to_smi_mpi(*args, **kwargs) + def plot_supported_format(self): """ print all supported file formats. diff --git a/groupy/gp_counter.py b/groupy/gp_counter.py index 0187ed0..b14ed80 100644 --- a/groupy/gp_counter.py +++ b/groupy/gp_counter.py @@ -3314,6 +3314,15 @@ def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result. print('Done!') return result + def count_mols_parallel(self, *args, **kwargs): + """ + Counting number of different groups of a batch of molecules with joblib parallelism. + + This is the preferred name for new code. It calls count_mols_mpi() + for backward compatibility with the original API. + """ + return self.count_mols_mpi(*args, **kwargs) + def get_group_fingerprint(self, mol): """ Getting group style fingerprint of a molecule. In fact, this method just call Counter.count_a_mol() and convert its result to a list. diff --git a/groupy/gp_generator.py b/groupy/gp_generator.py index 1d4475f..b219345 100644 --- a/groupy/gp_generator.py +++ b/groupy/gp_generator.py @@ -238,6 +238,15 @@ def batch_smi_to_gjf_mpi(self, smiles_file_path, gjf_root_path=None, result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) return result + def batch_smi_to_gjf_parallel(self, *args, **kwargs): + """ + Generating Gaussian job files with joblib parallelism. + + This is the preferred name for new code. It calls batch_smi_to_gjf_mpi() + for backward compatibility with the original API. + """ + return self.batch_smi_to_gjf_mpi(*args, **kwargs) + @staticmethod def write_gjf_link0_and_keyword(gjf_path, chk_path, nproc, mem, gaussian_keywords, charge_and_multiplicity, note, old_chk_path=None, add_link1=False): diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d2ad3b5..455cb6c 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -106,6 +106,31 @@ def fail_calculation(*args, **kwargs): self.assertEqual(error_path.read_text(encoding="utf-8"), "C1CCCC1\n") self.assertTrue(output_path.exists()) + def test_parallel_aliases_for_counter_and_calculator(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + count_output = tmp_path / "count_parallel.csv" + calculate_output = tmp_path / "calculate_parallel.csv" + input_path.write_text("C1CCCC1\n", encoding="utf-8") + + count_result = Counter().count_mols_parallel( + str(input_path), + count_result_file_path=str(count_output), + add_smiles=True, + n_jobs=1, + ) + calculate_result = Calculator().calculate_mols_parallel( + str(input_path), + properties_file_path=str(calculate_output), + n_jobs=1, + ) + + self.assertEqual(count_result.loc[0, "smiles"], "C1CCCC1") + self.assertEqual(count_result.loc[0, "f_168"], 5) + self.assertEqual(calculate_result.loc[0, "smiles"], "C1CCCC1") + self.assertAlmostEqual(calculate_result.loc[0, "molar_mass"], 70.135) + class SmilesFileSmokeTests(unittest.TestCase): def test_load_smiles_iterator_from_txt_csv_and_xlsx(self): @@ -191,6 +216,43 @@ def fake_smi_to_xyz(smi, xyz_path=None): self.assertEqual(fail_path.read_text(encoding="utf-8"), "invalid\n") self.assertEqual(succeed_path.read_text(encoding="utf-8"), "C1CCCC1\n") + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_convertor") + def test_convertor_parallel_aliases_call_legacy_methods(self): + from groupy.gp_convertor import Convertor + + convertor = Convertor() + calls = [] + + def fake_batch_smi_to_xyz_mpi(*args, **kwargs): + calls.append(("xyz", args, kwargs)) + return [True] + + def fake_batch_convert_file_type_mpi(*args, **kwargs): + calls.append(("convert", args, kwargs)) + return ["converted"] + + def fake_batch_file_to_smi_mpi(*args, **kwargs): + calls.append(("smi", args, kwargs)) + return ["C1CCCC1"] + + convertor.batch_smi_to_xyz_mpi = fake_batch_smi_to_xyz_mpi + convertor.batch_convert_file_type_mpi = fake_batch_convert_file_type_mpi + convertor.batch_file_to_smi_mpi = fake_batch_file_to_smi_mpi + + self.assertEqual( + convertor.batch_smi_to_xyz_parallel("smiles.txt", "xyz", n_jobs=1), + [True], + ) + self.assertEqual( + convertor.batch_convert_file_type_parallel("xyz", "in", "mol2", n_jobs=1), + ["converted"], + ) + self.assertEqual( + convertor.batch_file_to_smi_parallel("xyz", "in", n_jobs=1), + ["C1CCCC1"], + ) + self.assertEqual([call[0] for call in calls], ["xyz", "convert", "smi"]) + class GeneratorSmokeTests(unittest.TestCase): @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_generator") @@ -254,6 +316,26 @@ def fake_smi_to_gjf(smi, **kwargs): self.assertEqual(fail_path.read_text(encoding="utf-8"), "invalid\n") self.assertEqual(succeed_path.read_text(encoding="utf-8"), "C1CCCC1\n") + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_generator") + def test_generator_parallel_alias_calls_legacy_method(self): + from groupy.gp_generator import Generator + + generator = Generator() + calls = [] + + def fake_batch_smi_to_gjf_mpi(*args, **kwargs): + calls.append((args, kwargs)) + return [True] + + generator.batch_smi_to_gjf_mpi = fake_batch_smi_to_gjf_mpi + + self.assertEqual( + generator.batch_smi_to_gjf_parallel("smiles.txt", "gjf", n_jobs=1), + [True], + ) + self.assertEqual(calls[0][0], ("smiles.txt", "gjf")) + self.assertEqual(calls[0][1], {"n_jobs": 1}) + class CliSmokeTests(unittest.TestCase): def test_cli_import_does_not_load_openbabel_conversion_stack(self): From 76c3106d4facdb96943255779af591717e339c75 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:26:26 +0800 Subject: [PATCH 09/29] Lazy load OpenBabel conversion dependency --- README.md | 1 + REFACTORING_PLAN.md | 10 ++++++++-- groupy/gp_convertor.py | 16 ++++++++++++++-- tests/test_smoke.py | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 789200d..74fbd2d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Install for development: `conda install -c conda-forge openbabel`(**Do not** use `pip install openbabel` ) Then one can enter `Groupy` in terminal to start Groupy. +OpenBabel is only required for conversion and Gaussian input generation workflows. For non-interactive use, one can run commands such as: diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index cacb4bb..69a335d 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -124,8 +124,8 @@ Current file handling status: ## Phase 6: Dependency Boundaries And Lazy Imports -- [ ] Keep core counting and property calculation independent from OpenBabel. -- [ ] Lazy-load OpenBabel only inside conversion functions that need it. +- [x] Keep core counting and property calculation independent from OpenBabel. +- [x] Lazy-load OpenBabel only inside conversion functions that need it. - [ ] Lazy-load visualization dependencies only inside viewer functions that need them. - [ ] Consider optional dependency groups: - `.[convert]` @@ -133,6 +133,12 @@ Current file handling status: - `.[dev]` - [ ] Document that OpenBabel should usually be installed from conda-forge. +Current dependency boundary status: + +- `groupy.gp_convertor` and `groupy.gp_generator` can be imported without importing OpenBabel. +- OpenBabel is loaded through `groupy.gp_convertor._load_pybel()` only when a conversion feature needs Pybel. +- Missing OpenBabel now raises an install hint for the relevant conversion feature. + ## Phase 7: Data Loading And Performance - [ ] Cache `Loader` results so Excel files are not repeatedly parsed. diff --git a/groupy/gp_convertor.py b/groupy/gp_convertor.py index d652f9b..c6fa3a5 100644 --- a/groupy/gp_convertor.py +++ b/groupy/gp_convertor.py @@ -1,8 +1,6 @@ import os from rdkit import Chem from rdkit.Chem import AllChem -from openbabel import pybel -import pandas as pd from tqdm import tqdm from joblib import Parallel, delayed from pprint import pprint @@ -11,6 +9,17 @@ from groupy.io import write_text_lines +def _load_pybel(): + try: + from openbabel import pybel + except ImportError as exc: + raise ImportError( + "OpenBabel is required for this conversion feature. " + "Install it with `conda install -c conda-forge openbabel`." + ) from exc + return pybel + + class Convertor: """ A class for converting between SMILES and different file formats (such as xyz, pdb, gro...). Users can call @@ -43,6 +52,7 @@ def smi_to_xyz(smi, xyz_path=None): AllChem.MMFFOptimizeMolecule(mol_with_h) opt = Chem.MolToMolBlock(mol_with_h) except ValueError: + pybel = _load_pybel() mol = pybel.readstring("smi", smi) mol.addh() if mol.make3D() is None: @@ -167,6 +177,7 @@ def convert_file_type(in_format, in_path, out_format, out_path=None): :param out_path: str. Target file path. If set to None, out_path will be same as in_path except its suffix. """ try: + pybel = _load_pybel() mol = pybel.readfile(in_format, in_path).__next__() # print('The SMILES of this system is :') # print(mol.write('smi')) @@ -273,6 +284,7 @@ def file_to_smi(file_path, format=None): :return: SMILES """ try: + pybel = _load_pybel() atoms = next(pybel.readfile(format=format, filename=file_path)) smi = atoms.write(format='smi').split('\t')[0] # print(smi) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 455cb6c..b64186b 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,4 +1,5 @@ import importlib.util +import builtins import json import os import subprocess @@ -155,6 +156,46 @@ def test_load_smiles_iterator_from_txt_csv_and_xlsx(self): class ConvertorSmokeTests(unittest.TestCase): + def test_conversion_modules_do_not_import_openbabel_at_import_time(self): + command = [ + sys.executable, + "-c", + ( + "import sys; " + "import groupy.gp_convertor; " + "import groupy.gp_generator; " + "print('openbabel' in sys.modules)" + ), + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stdout.strip(), "False") + + def test_lazy_pybel_import_reports_install_hint_when_missing(self): + from groupy.gp_convertor import _load_pybel + + original_import = builtins.__import__ + + def block_openbabel(name, globals=None, locals=None, fromlist=(), level=0): + if name == "openbabel": + raise ImportError("blocked for test") + return original_import(name, globals, locals, fromlist, level) + + builtins.__import__ = block_openbabel + try: + with self.assertRaisesRegex(ImportError, "conda install -c conda-forge openbabel"): + _load_pybel() + finally: + builtins.__import__ = original_import + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_convertor") def test_batch_smi_to_xyz_does_not_write_logs_by_default(self): from groupy.gp_convertor import Convertor From 10831f7e64ef1eec7e36d6c7b28d98d38e5e4e45 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:29:56 +0800 Subject: [PATCH 10/29] Lazy load visualization dependency --- README.md | 1 + REFACTORING_PLAN.md | 4 +++- groupy/gp_viewer.py | 39 ++++++++++++++++++++++++++++++++++----- tests/test_smoke.py | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 74fbd2d..34c0323 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Install for development: Then one can enter `Groupy` in terminal to start Groupy. OpenBabel is only required for conversion and Gaussian input generation workflows. +ASE is only required for molecular visualization workflows. For non-interactive use, one can run commands such as: diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 69a335d..96c576a 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -126,7 +126,7 @@ Current file handling status: - [x] Keep core counting and property calculation independent from OpenBabel. - [x] Lazy-load OpenBabel only inside conversion functions that need it. -- [ ] Lazy-load visualization dependencies only inside viewer functions that need them. +- [x] Lazy-load visualization dependencies only inside viewer functions that need them. - [ ] Consider optional dependency groups: - `.[convert]` - `.[viewer]` @@ -138,6 +138,8 @@ Current dependency boundary status: - `groupy.gp_convertor` and `groupy.gp_generator` can be imported without importing OpenBabel. - OpenBabel is loaded through `groupy.gp_convertor._load_pybel()` only when a conversion feature needs Pybel. - Missing OpenBabel now raises an install hint for the relevant conversion feature. +- `groupy.gp_viewer` can be imported without importing ASE. +- ASE is loaded through viewer helpers only when molecular visualization needs it. ## Phase 7: Data Loading And Performance diff --git a/groupy/gp_viewer.py b/groupy/gp_viewer.py index 680dd6b..2ab00d2 100644 --- a/groupy/gp_viewer.py +++ b/groupy/gp_viewer.py @@ -1,12 +1,39 @@ -import ase -from ase.visualize import view -from ase.io import read import os from pprint import pprint from groupy.gp_convertor import Convertor +_ASE_INSTALL_HINT = ( + "ASE is required for molecular visualization. " + "Install it with `pip install ase` or `conda install -c conda-forge ase`." +) + + +def _load_ase_read(): + try: + from ase.io import read + except ImportError as exc: + raise ImportError(_ASE_INSTALL_HINT) from exc + return read + + +def _load_ase_view(): + try: + from ase.visualize import view + except ImportError as exc: + raise ImportError(_ASE_INSTALL_HINT) from exc + return view + + +def _load_ase_formats(): + try: + from ase.io.formats import ioformats + except ImportError as exc: + raise ImportError(_ASE_INSTALL_HINT) from exc + return ioformats + + class Viewer: """ A class for visualizing SMILES and various chemical files. Users can call Viewer.plot_supported_format() to print @@ -23,6 +50,8 @@ def view_mol(self, mol, mol_type='smi'): :param mol_type: str. :return: None. """ + read = _load_ase_read() + view = _load_ase_view() if isinstance(mol, str): if mol_type in ['smi', 'smiles', 'SMILES']: convertor = Convertor() @@ -45,7 +74,7 @@ def plot_supported_format(self): print all supported file formats. :return: None. """ - ase_format = ase.io.formats.ioformats + ase_format = _load_ase_formats() openbabel_format = {'abinit': 'ABINIT Output Format', 'acesout': 'ACES output format', 'acr': 'ACR format', 'adfband': 'ADF Band output format', 'adfdftb': 'ADF DFTB output format', 'adfout': 'ADF output format', 'alc': 'Alchemy format', 'aoforce': 'Turbomole AOFORCE output format', @@ -109,4 +138,4 @@ def plot_supported_format(self): # # viewer = Viewer() # viewer.view_mol('gp_3x_test_mol/test_xyz/000000.mol2', mol_type='mol2') -# viewer.plot_supported_format() \ No newline at end of file +# viewer.plot_supported_format() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index b64186b..75bc4ee 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -378,6 +378,43 @@ def fake_batch_smi_to_gjf_mpi(*args, **kwargs): self.assertEqual(calls[0][1], {"n_jobs": 1}) +class ViewerSmokeTests(unittest.TestCase): + def test_viewer_module_does_not_import_ase_at_import_time(self): + command = [ + sys.executable, + "-c", + "import sys; import groupy.gp_viewer; print('ase' in sys.modules)", + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stdout.strip(), "False") + + def test_lazy_ase_import_reports_install_hint_when_missing(self): + from groupy.gp_viewer import _load_ase_read + + original_import = builtins.__import__ + + def block_ase(name, globals=None, locals=None, fromlist=(), level=0): + if name == "ase" or name.startswith("ase."): + raise ImportError("blocked for test") + return original_import(name, globals, locals, fromlist, level) + + builtins.__import__ = block_ase + try: + with self.assertRaisesRegex(ImportError, "conda install -c conda-forge ase"): + _load_ase_read() + finally: + builtins.__import__ = original_import + + class CliSmokeTests(unittest.TestCase): def test_cli_import_does_not_load_openbabel_conversion_stack(self): command = [ From 6fa99ec0286fc7763e384c0c39fa2fe83846455c Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:34:35 +0800 Subject: [PATCH 11/29] Split optional visualization dependencies --- README.md | 8 +++++++- REFACTORING_PLAN.md | 26 ++++++++++++++------------ pyproject.toml | 5 ++++- tests/test_smoke.py | 17 +++++++++++++++++ 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 34c0323..17f7551 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,13 @@ Install for development: `python -m pip install -e .` -`conda install -c conda-forge openbabel`(**Do not** use `pip install openbabel` ) +Install visualization support when needed: + +`python -m pip install -e ".[viewer]"` + +Install conversion and Gaussian input generation support when needed: + +`conda install -c conda-forge openbabel` (**Do not** use `pip install openbabel`.) Then one can enter `Groupy` in terminal to start Groupy. OpenBabel is only required for conversion and Gaussian input generation workflows. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 96c576a..fe78025 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -127,11 +127,11 @@ Current file handling status: - [x] Keep core counting and property calculation independent from OpenBabel. - [x] Lazy-load OpenBabel only inside conversion functions that need it. - [x] Lazy-load visualization dependencies only inside viewer functions that need them. -- [ ] Consider optional dependency groups: +- [x] Consider optional dependency groups: - `.[convert]` - `.[viewer]` - `.[dev]` -- [ ] Document that OpenBabel should usually be installed from conda-forge. +- [x] Document that OpenBabel should usually be installed from conda-forge. Current dependency boundary status: @@ -140,6 +140,10 @@ Current dependency boundary status: - Missing OpenBabel now raises an install hint for the relevant conversion feature. - `groupy.gp_viewer` can be imported without importing ASE. - ASE is loaded through viewer helpers only when molecular visualization needs it. +- Core installation no longer depends on ASE or OpenBabel. +- `.[viewer]` installs ASE for molecular visualization workflows. +- `.[convert]` is intentionally empty because OpenBabel should usually be installed from conda-forge. +- `.[dev]` installs build and test tooling. ## Phase 7: Data Loading And Performance @@ -160,10 +164,10 @@ Current parallel API status: ## Phase 8: Documentation And CI - [ ] Update README with: - - editable install - - conda/OpenBabel note - - library API examples - - CLI examples + - [x] editable install + - [x] conda/OpenBabel note + - [ ] library API examples + - [x] CLI examples - [ ] Add a build check: - `python -m build` - [ ] Add GitHub Actions for: @@ -199,10 +203,8 @@ Current parallel API status: ## Near-Term Next Step -Start with Phase 1 and Phase 2: +Continue with Phase 7: -1. Remove generated artifacts from Git tracking. -2. Add the first `pytest` smoke tests. -3. Run editable install, compile, and tests after each small change. - -After Phase 2, prioritize Phase 3 API/CLI separation because the future GUI should call those same stable APIs. +1. Cache `Loader` results so bundled Excel files are not repeatedly parsed. +2. Add regression coverage around the cache behavior. +3. Keep old public loader behavior unchanged while improving performance. diff --git a/pyproject.toml b/pyproject.toml index 5b49bfb..01a6718 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ authors = [ { name = "Ruichen Liu", email = "1197748182@qq.com" }, ] dependencies = [ - "ase", "joblib", "numpy", "openpyxl", @@ -22,6 +21,10 @@ dependencies = [ ] [project.optional-dependencies] +convert = [] +viewer = [ + "ase", +] dev = [ "build", "pytest", diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 75bc4ee..4a03bd5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -29,6 +29,23 @@ def test_loader_reads_bundled_data(self): self.assertTrue(all(group_orders)) +class PackagingSmokeTests(unittest.TestCase): + @unittest.skipUnless(importlib.util.find_spec("tomllib"), "tomllib is available on Python 3.11+") + def test_optional_dependency_groups_keep_viewer_out_of_core(self): + import tomllib + + pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" + project = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))["project"] + + dependencies = project["dependencies"] + optional_dependencies = project["optional-dependencies"] + + self.assertNotIn("ase", dependencies) + self.assertEqual(optional_dependencies["viewer"], ["ase"]) + self.assertEqual(optional_dependencies["convert"], []) + self.assertIn("pytest", optional_dependencies["dev"]) + + class CoreChemistrySmokeTests(unittest.TestCase): def test_counter_counts_cyclopentane(self): result = Counter().count_a_mol("C1CCCC1", clear_mode=True, add_smiles=True) From 6696fac21961211c98542b5d73d423a826704369 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:37:57 +0800 Subject: [PATCH 12/29] Cache bundled loader data --- REFACTORING_PLAN.md | 16 +++++-- groupy/gp_loader.py | 107 +++++++++++++++++++++++++++++++------------- tests/test_smoke.py | 54 ++++++++++++++++++++++ 3 files changed, 141 insertions(+), 36 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index fe78025..5cdbe48 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -147,11 +147,19 @@ Current dependency boundary status: ## Phase 7: Data Loading And Performance -- [ ] Cache `Loader` results so Excel files are not repeatedly parsed. +- [x] Cache `Loader` results so Excel files are not repeatedly parsed. +- [x] Add regression coverage around cached loader reads. - [ ] Keep SMARTS/group-counting logic behaviorally unchanged until tests cover it better. - [x] Rename or alias `*_mpi` APIs to `*_parallel`, since they use `joblib`, not MPI. - [x] Preserve old method names during transition for backward compatibility. +Current data loading status: + +- `Loader.load_parameters()` caches bundled Excel parameter tables per parameter type. +- `Loader.load_group_order()` caches bundled group order tables. +- `Loader.clear_cache()` is available for tests and advanced workflows that need to refresh cached internal data. +- Public loader methods return independent copies so callers cannot mutate the process-wide cache by accident. + Current parallel API status: - `Calculator.calculate_mols_parallel()` calls `calculate_mols_mpi()`. @@ -205,6 +213,6 @@ Current parallel API status: Continue with Phase 7: -1. Cache `Loader` results so bundled Excel files are not repeatedly parsed. -2. Add regression coverage around the cache behavior. -3. Keep old public loader behavior unchanged while improving performance. +1. Keep SMARTS/group-counting behavior unchanged while adding narrower tests around representative groups. +2. Start replacing broad exception handling in batch paths with explicit errors and logging. +3. After the core API is stable enough, begin the minimal PySide6 GUI shell. diff --git a/groupy/gp_loader.py b/groupy/gp_loader.py index 98bb7a4..321ed94 100644 --- a/groupy/gp_loader.py +++ b/groupy/gp_loader.py @@ -1,61 +1,104 @@ -import pandas as pd +from copy import deepcopy +from functools import lru_cache from importlib.resources import as_file, files +import pandas as pd + + +def _data_paths(): + data_root = files('groupy') + return { + 'parameters': [data_root / 'group_contribution_parameters.xlsx'], + 'group_order': [data_root / 'group_order.xlsx'], + } + + +@lru_cache(maxsize=None) +def _load_parameter_tables(parameter_type): + parameters_path = _data_paths()['parameters'] + for path in parameters_path: + if not path.is_file(): + continue + first_order = Loader._read_excel( + path, + sheet_name=f'{parameter_type}_first_order', + index_col='index', + ).T.to_dict() + second_order = Loader._read_excel( + path, + sheet_name=f'{parameter_type}_second_order', + index_col='index', + ).T.to_dict() + third_order = Loader._read_excel( + path, + sheet_name=f'{parameter_type}_third_order', + index_col='index', + ).T.to_dict() + universal_constants = Loader._read_excel( + path, + sheet_name=f'{parameter_type}_constants', + index_col='index', + ).T.to_dict() + return first_order, second_order, third_order, universal_constants + raise FileNotFoundError(f'Can not find group_contribution_parameters.xlsx in {parameters_path}') + + +@lru_cache(maxsize=None) +def _load_group_order_tables(): + group_order_path = _data_paths()['group_order'] + for path in group_order_path: + if not path.is_file(): + continue + f_order_group_function_order = ( + Loader._read_excel(path, sheet_name='f')[ + 'index'] - 1).tolist() # 减1是为了基团序号和列表索引对上,因为python列表里的索引是从0开始的 + s_order_group_function_order = ( + Loader._read_excel(path, sheet_name='s')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 + t_order_group_function_order = ( + Loader._read_excel(path, sheet_name='t')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 + return f_order_group_function_order, s_order_group_function_order, t_order_group_function_order + raise FileNotFoundError(f'Can not find group_order.xlsx in {group_order_path}') + class Loader: """ A class for loading internal date of Groupy. Usually, users do not need to utilize this Python class. """ def __init__(self, ): - data_root = files('groupy') - self.parameters_path = [data_root / 'group_contribution_parameters.xlsx'] - self.group_order_path = [data_root / 'group_order.xlsx'] + paths = _data_paths() + self.parameters_path = paths['parameters'] + self.group_order_path = paths['group_order'] @staticmethod def _read_excel(data_file, **kwargs): with as_file(data_file) as path: return pd.read_excel(path, **kwargs) + @staticmethod + def clear_cache(): + _load_parameter_tables.cache_clear() + _load_group_order_tables.cache_clear() + def load_parameters(self, parameter_type='simultaneous', split=False): """ Loading parameters of group contribution method for groupy.gp_calculator.Calculator. """ assert parameter_type in ['simultaneous', 'step_wise'], '请确保参数类型为simultaneous或step_wise!' - for path in self.parameters_path: - if not path.is_file(): - continue - step_wise_first_order = self._read_excel(path, sheet_name='{}_first_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_second_order = self._read_excel(path, sheet_name='{}_second_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_third_order = self._read_excel(path, sheet_name='{}_third_order'.format(parameter_type), index_col='index').T.to_dict() - step_wise_universal_constants = self._read_excel(path, sheet_name='{}_constants'.format(parameter_type), index_col='index').T.to_dict() - break - else: - raise FileNotFoundError(f'Can not find group_contribution_parameters.xlsx in {self.parameters_path}') - + parameter_tables = _load_parameter_tables(parameter_type) if split: - return step_wise_first_order, step_wise_second_order, step_wise_third_order, step_wise_universal_constants - else: - return {**step_wise_first_order, **step_wise_second_order, **step_wise_third_order, **step_wise_universal_constants} + return deepcopy(parameter_tables) + + parameters = {} + for table in parameter_tables: + parameters.update(deepcopy(table)) + return parameters def load_group_order(self): """ Loading order of group for groupy.gp_counter.Counter """ - for path in self.group_order_path: - if not path.is_file(): - continue - f_order_group_function_order = ( - self._read_excel(path, sheet_name='f')[ - 'index'] - 1).tolist() # 减1是为了基团序号和列表索引对上,因为python列表里的索引是从0开始的 - s_order_group_function_order = ( - self._read_excel(path, sheet_name='s')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - t_order_group_function_order = ( - self._read_excel(path, sheet_name='t')['index'] - 1).tolist() # 减1是为了基团序号和列表索引对上 - break - else: - raise FileNotFoundError(f'Can not find group_order.xlsx in {self.parameters_path}') - return f_order_group_function_order, s_order_group_function_order, t_order_group_function_order + return deepcopy(_load_group_order_tables()) # if __name__ == '__main__': diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 4a03bd5..208beed 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -18,6 +18,9 @@ class LoaderSmokeTests(unittest.TestCase): + def tearDown(self): + Loader.clear_cache() + def test_loader_reads_bundled_data(self): loader = Loader() @@ -28,6 +31,57 @@ def test_loader_reads_bundled_data(self): self.assertEqual(group_orders[0][:3], [104, 105, 93]) self.assertTrue(all(group_orders)) + def test_loader_caches_bundled_excel_reads(self): + Loader.clear_cache() + read_calls = [] + original_read_excel = Loader._read_excel + + def counting_read_excel(data_file, **kwargs): + read_calls.append(kwargs["sheet_name"]) + return original_read_excel(data_file, **kwargs) + + Loader._read_excel = staticmethod(counting_read_excel) + try: + loader = Loader() + loader.load_parameters(parameter_type="simultaneous") + Loader().load_parameters(parameter_type="simultaneous") + loader.load_group_order() + Loader().load_group_order() + finally: + Loader._read_excel = staticmethod(original_read_excel) + + self.assertEqual( + read_calls, + [ + "simultaneous_first_order", + "simultaneous_second_order", + "simultaneous_third_order", + "simultaneous_constants", + "f", + "s", + "t", + ], + ) + + def test_loader_returns_independent_copies_from_cache(self): + Loader.clear_cache() + loader = Loader() + + parameters = loader.load_parameters() + parameter_key = next(iter(parameters)) + property_key = next(iter(parameters[parameter_key])) + original_value = parameters[parameter_key][property_key] + parameters[parameter_key][property_key] = "changed" + + group_orders = loader.load_group_order() + group_orders[0].append(999999) + + fresh_parameters = Loader().load_parameters() + fresh_group_orders = Loader().load_group_order() + + self.assertEqual(fresh_parameters[parameter_key][property_key], original_value) + self.assertNotIn(999999, fresh_group_orders[0]) + class PackagingSmokeTests(unittest.TestCase): @unittest.skipUnless(importlib.util.find_spec("tomllib"), "tomllib is available on Python 3.11+") From 572f8ddb95c4c0eeed257bb33cda4e7d51cef0ac Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:44:19 +0800 Subject: [PATCH 13/29] Improve core molecule error reporting --- REFACTORING_PLAN.md | 19 +++++++++-- groupy/__init__.py | 6 ++++ groupy/chem.py | 18 +++++++++++ groupy/exceptions.py | 9 ++++++ groupy/gp_calculator.py | 58 +++++++++++++++++++-------------- groupy/gp_counter.py | 12 ++++--- tests/test_smoke.py | 72 +++++++++++++++++++++++++++++++++-------- 7 files changed, 149 insertions(+), 45 deletions(-) create mode 100644 groupy/chem.py create mode 100644 groupy/exceptions.py diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 5cdbe48..b6a2c04 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -116,11 +116,24 @@ Current file handling status: ## Phase 5: Exceptions, Logging, And Error Reporting - [ ] Replace bare `except:` blocks with specific exceptions. -- [ ] Replace `raise NotImplemented(...)` with `NotImplementedError` or `ValueError`. + - [x] `Calculator.calculate_a_mol()` now catches expected calculation/input errors explicitly. + - [x] `Counter.count_a_mol()` now catches expected invalid-input errors explicitly. +- [x] Replace `raise NotImplemented(...)` with `NotImplementedError` or `ValueError`. - [ ] Move user-facing `print()` calls toward the CLI layer. - [ ] Use `logging` in library code. + - [x] Use module loggers for invalid SMILES in core calculator/counter paths. - [ ] For batch processing, continue processing failed molecules only when configured to do so. - [ ] Preserve enough failure detail to debug invalid SMILES, unsupported formats, and dependency problems. + - [x] Add `InvalidSmilesError` for failed SMILES parsing. + - [x] Failed property calculation records now include an `error` field. + - [x] Batch calculation writes requested error SMILES from structured failed records. + +Current exception/error reporting status: + +- `groupy.chem.ensure_mol()` validates SMILES and suppresses low-level RDKit parse logs. +- `groupy.exceptions.InvalidSmilesError` represents expected SMILES parsing failures. +- Invalid single-molecule calculations return the legacy placeholder values plus a diagnostic `error` field. +- Unexpected bugs in core calculation/counting are no longer hidden by bare `except:` blocks. ## Phase 6: Dependency Boundaries And Lazy Imports @@ -213,6 +226,6 @@ Current parallel API status: Continue with Phase 7: -1. Keep SMARTS/group-counting behavior unchanged while adding narrower tests around representative groups. -2. Start replacing broad exception handling in batch paths with explicit errors and logging. +1. Continue replacing broad exception handling in conversion/generation and legacy interactive paths. +2. Move user-facing `print()` calls out of library batch methods where practical. 3. After the core API is stable enough, begin the minimal PySide6 GUI shell. diff --git a/groupy/__init__.py b/groupy/__init__.py index ee4f004..e56d053 100644 --- a/groupy/__init__.py +++ b/groupy/__init__.py @@ -1,10 +1,16 @@ """Groupy package namespace.""" +import logging + __version__ = "3.0.0" +logging.getLogger(__name__).addHandler(logging.NullHandler()) + __all__ = [ "api", + "chem", "cli", + "exceptions", "io", "gp_loader", "gp_tool", diff --git a/groupy/chem.py b/groupy/chem.py new file mode 100644 index 0000000..44c1acf --- /dev/null +++ b/groupy/chem.py @@ -0,0 +1,18 @@ +"""Small chemistry helpers shared by Groupy modules.""" + +from rdkit import Chem, rdBase + +from groupy.exceptions import InvalidSmilesError + + +def ensure_mol(mol): + """Return an RDKit molecule from a SMILES string or molecule-like object.""" + if isinstance(mol, str): + with rdBase.BlockLogs(): + parsed = Chem.MolFromSmiles(mol) + if parsed is None: + raise InvalidSmilesError(f"Invalid SMILES: {mol}") + return parsed + if mol is None or not hasattr(mol, "GetAtoms"): + raise TypeError("Expected a SMILES string or an RDKit molecule.") + return mol diff --git a/groupy/exceptions.py b/groupy/exceptions.py new file mode 100644 index 0000000..c81d848 --- /dev/null +++ b/groupy/exceptions.py @@ -0,0 +1,9 @@ +"""Custom exceptions used by Groupy library APIs.""" + + +class GroupyError(Exception): + """Base class for expected Groupy runtime errors.""" + + +class InvalidSmilesError(GroupyError, ValueError): + """Raised when a SMILES string cannot be parsed into a molecule.""" diff --git a/groupy/gp_calculator.py b/groupy/gp_calculator.py index d419fcc..42880d7 100644 --- a/groupy/gp_calculator.py +++ b/groupy/gp_calculator.py @@ -1,13 +1,37 @@ +import logging from rdkit import Chem from math import log import pandas as pd from tqdm import tqdm from joblib import Parallel, delayed +from groupy.chem import ensure_mol +from groupy.exceptions import InvalidSmilesError from groupy.gp_loader import Loader from groupy.gp_counter import Counter from groupy.io import load_smiles_file, write_text_lines +logger = logging.getLogger(__name__) + + +def _failed_calculation_result(smiles, error): + return {'smiles': smiles, + 'molar_mass': '?', + 'flash_point/K': '?', + 'Tm/K': '?', 'Tb/K': '?', 'Tc/K': '?', + 'Pc/bar': '?', 'Vc/(cm3/mol)': '?', + 'density/(g/cm3)': '?', + 'delta_G/(KJ/mol)': '?', + 'delta_Hf/(KJ/mol)': '?', + 'delta_Hvap/(KJ/mol)': '?', + 'delta_Hfus/(KJ/mol)': '?', + 'molar_volume/(cm3/mol)(default298K)': '?', + 'delta_Hc/(KJ/mol)': '?', + 'mass_calorific_value_h/(MJ/kg)': '?', + 'ISP': '?', + 'note': 'There must be something wrong with this SMILES', + 'error': error} + class Calculator: """ @@ -344,9 +368,11 @@ def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=Tru 'note': value} """ init_smi = mol + if parameter_type not in ['step_wise', 'simultaneous']: + raise ValueError('parameter_type must be "step_wise" or "simultaneous".') + try: - if isinstance(mol, str): - mol = Chem.MolFromSmiles(mol) + mol = ensure_mol(mol) group_number = self.counter.count_a_mol(mol, clear_mode=True, add_note=True) if group_number.get('note', ''): counter_note = group_number['note'] @@ -358,8 +384,6 @@ def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=Tru parameters = self.parameters_step_wise elif parameter_type == 'simultaneous': parameters = self.parameters_simultaneous - else: - raise NotImplemented('不可用的参数类型,只能使用step_wise或simultaneous') if debug: print(group_number) @@ -410,23 +434,9 @@ def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=Tru 'mass_calorific_value_h/(MJ/kg)': q, 'ISP': isp, 'note': counter_note + ' at 298K'} - except: - print(f'Error! There is something wrong when calculating {init_smi}, please check it.') - return {'smiles': init_smi, - 'molar_mass': '?', - 'flash_point/K': '?', - 'Tm/K': '?', 'Tb/K': '?', 'Tc/K': '?', - 'Pc/bar': '?', 'Vc/(cm3/mol)': '?', - 'density/(g/cm3)': '?', - 'delta_G/(KJ/mol)': '?', - 'delta_Hf/(KJ/mol)': '?', - 'delta_Hvap/(KJ/mol)': '?', - 'delta_Hfus/(KJ/mol)': '?', - 'molar_volume/(cm3/mol)(default298K)': '?', - 'delta_Hc/(KJ/mol)': '?', - 'mass_calorific_value_h/(MJ/kg)': '?', - 'ISP': '?', - 'note': 'There must be something wrong with this SMILES'} + except (InvalidSmilesError, TypeError, ValueError, KeyError, AttributeError, ArithmeticError) as exc: + logger.warning("Failed to calculate properties for %r: %s", init_smi, exc) + return _failed_calculation_result(init_smi, str(exc)) def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.csv', check_hydrocarbon=True, parameter_type='step_wise', error_file_path=None): @@ -452,9 +462,9 @@ def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.cs properties_dict_list = [] error_smi = [] for i in tqdm(smiles_iterator): - try: - properties_dict_list.append(self.calculate_a_mol(i, check_hydrocarbon=check_hydrocarbon, parameter_type=parameter_type)) - except Exception: + result = self.calculate_a_mol(i, check_hydrocarbon=check_hydrocarbon, parameter_type=parameter_type) + properties_dict_list.append(result) + if result.get('error'): error_smi.append(i) print('calculation completed!') print('start to export result to {} ...'.format(properties_file_path)) diff --git a/groupy/gp_counter.py b/groupy/gp_counter.py index b14ed80..2309544 100644 --- a/groupy/gp_counter.py +++ b/groupy/gp_counter.py @@ -1,13 +1,18 @@ +import logging + from tqdm import tqdm from rdkit import Chem import pandas as pd from joblib import Parallel, delayed +from groupy.chem import ensure_mol +from groupy.exceptions import InvalidSmilesError from groupy.gp_loader import Loader from groupy.io import load_smiles_file __all__ = ["Counter", ] +logger = logging.getLogger(__name__) # tool @@ -3238,8 +3243,7 @@ def count_a_mol(self, mol, clear_mode=False, add_note=False, add_smiles=False): """ init_smi = mol try: - if isinstance(mol, str): - mol = Chem.MolFromSmiles(mol) + mol = ensure_mol(mol) self.result = self.init_result.copy() if add_note: # self.result['note'] = '' @@ -3253,8 +3257,8 @@ def count_a_mol(self, mol, clear_mode=False, add_note=False, add_smiles=False): # 清爽模式,不显示没有统计到的基团 self.result = {k: v for k, v in self.result.items() if v} return self.result - except: - print(f'Error! There is something wrong when counting {init_smi}, please check it.') + except (InvalidSmilesError, TypeError) as exc: + logger.warning("Failed to count groups for %r: %s", init_smi, exc) return self.init_result.copy() def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv', add_note=False, add_smiles=False): diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 208beed..1ca6705 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -115,6 +115,28 @@ def test_calculator_calculates_cyclopentane(self): self.assertAlmostEqual(result["Pc/bar"], 42.659) self.assertEqual(result["note"], "C1CCCC1 at 298K") + def test_calculator_reports_invalid_smiles(self): + with self.assertLogs("groupy.gp_calculator", level="WARNING") as log_context: + result = Calculator().calculate_a_mol("not-a-smiles") + + self.assertEqual(result["smiles"], "not-a-smiles") + self.assertEqual(result["molar_mass"], "?") + self.assertIn("Invalid SMILES", result["error"]) + self.assertIn("Failed to calculate properties", log_context.output[0]) + + def test_calculator_rejects_unknown_parameter_type(self): + with self.assertRaisesRegex(ValueError, "parameter_type"): + Calculator().calculate_a_mol("C1CCCC1", parameter_type="unknown") + + def test_counter_logs_invalid_smiles(self): + counter = Counter() + + with self.assertLogs("groupy.gp_counter", level="WARNING") as log_context: + result = counter.count_a_mol("not-a-smiles") + + self.assertEqual(result, counter.init_result) + self.assertIn("Failed to count groups", log_context.output[0]) + def test_counter_batch_uses_shared_smiles_loader(self): with tempfile.TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) @@ -160,22 +182,19 @@ def test_calculator_batch_writes_error_file_only_when_requested(self): input_path = tmp_path / "smiles.txt" output_path = tmp_path / "calculate.csv" error_path = tmp_path / "errors.txt" - input_path.write_text("C1CCCC1\n", encoding="utf-8") - - calculator = Calculator() - - def fail_calculation(*args, **kwargs): - raise ValueError("forced failure") + input_path.write_text("not-a-smiles\n", encoding="utf-8") - calculator.calculate_a_mol = fail_calculation - result = calculator.calculate_mols( - str(input_path), - properties_file_path=str(output_path), - error_file_path=str(error_path), - ) + with self.assertLogs("groupy.gp_calculator", level="WARNING"): + result = Calculator().calculate_mols( + str(input_path), + properties_file_path=str(output_path), + error_file_path=str(error_path), + ) - self.assertTrue(result.empty) - self.assertEqual(error_path.read_text(encoding="utf-8"), "C1CCCC1\n") + self.assertEqual(result.loc[0, "smiles"], "not-a-smiles") + self.assertEqual(result.loc[0, "molar_mass"], "?") + self.assertIn("Invalid SMILES", result.loc[0, "error"]) + self.assertEqual(error_path.read_text(encoding="utf-8"), "not-a-smiles\n") self.assertTrue(output_path.exists()) def test_parallel_aliases_for_counter_and_calculator(self): @@ -577,6 +596,31 @@ def test_calculate_cli_outputs_json(self): self.assertAlmostEqual(result["Tb/K"], 308.65) self.assertEqual(result["note"], "C1CCCC1 at 298K") + def test_calculate_cli_reports_invalid_smiles_as_json(self): + command = [ + sys.executable, + "-m", + "groupy.cli", + "calculate", + "--smiles", + "not-a-smiles", + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stderr, "") + result = json.loads(completed.stdout) + self.assertEqual(result["smiles"], "not-a-smiles") + self.assertEqual(result["molar_mass"], "?") + self.assertIn("Invalid SMILES", result["error"]) + def test_calculate_cli_writes_csv_from_input_file(self): with tempfile.TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) From 75020ec2fa6790dc0bb5aecdbdcc7c703cec6c96 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:48:34 +0800 Subject: [PATCH 14/29] Improve conversion error handling --- REFACTORING_PLAN.md | 6 ++++ groupy/exceptions.py | 4 +++ groupy/gp_convertor.py | 47 ++++++++++++++---------------- groupy/gp_generator.py | 32 ++++++++++++-------- tests/test_smoke.py | 66 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 38 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index b6a2c04..23016d1 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -118,10 +118,13 @@ Current file handling status: - [ ] Replace bare `except:` blocks with specific exceptions. - [x] `Calculator.calculate_a_mol()` now catches expected calculation/input errors explicitly. - [x] `Counter.count_a_mol()` now catches expected invalid-input errors explicitly. + - [x] `Convertor` file conversion paths now catch explicit conversion exceptions. + - [x] `Generator.smi_to_gjf()` now catches explicit generation/input exceptions. - [x] Replace `raise NotImplemented(...)` with `NotImplementedError` or `ValueError`. - [ ] Move user-facing `print()` calls toward the CLI layer. - [ ] Use `logging` in library code. - [x] Use module loggers for invalid SMILES in core calculator/counter paths. + - [x] Use module loggers for conversion and Gaussian input generation failures. - [ ] For batch processing, continue processing failed molecules only when configured to do so. - [ ] Preserve enough failure detail to debug invalid SMILES, unsupported formats, and dependency problems. - [x] Add `InvalidSmilesError` for failed SMILES parsing. @@ -132,8 +135,11 @@ Current exception/error reporting status: - `groupy.chem.ensure_mol()` validates SMILES and suppresses low-level RDKit parse logs. - `groupy.exceptions.InvalidSmilesError` represents expected SMILES parsing failures. +- `groupy.exceptions.ConversionError` represents expected molecule/file conversion failures. - Invalid single-molecule calculations return the legacy placeholder values plus a diagnostic `error` field. - Unexpected bugs in core calculation/counting are no longer hidden by bare `except:` blocks. +- Conversion and Gaussian generation no longer hide missing OpenBabel install errors. +- `Generator.smi_to_gjf()` cleans temporary xyz files in a `finally` block. ## Phase 6: Dependency Boundaries And Lazy Imports diff --git a/groupy/exceptions.py b/groupy/exceptions.py index c81d848..ea093d6 100644 --- a/groupy/exceptions.py +++ b/groupy/exceptions.py @@ -7,3 +7,7 @@ class GroupyError(Exception): class InvalidSmilesError(GroupyError, ValueError): """Raised when a SMILES string cannot be parsed into a molecule.""" + + +class ConversionError(GroupyError): + """Raised when a molecule or file conversion step fails.""" diff --git a/groupy/gp_convertor.py b/groupy/gp_convertor.py index c6fa3a5..deb14ab 100644 --- a/groupy/gp_convertor.py +++ b/groupy/gp_convertor.py @@ -1,3 +1,4 @@ +import logging import os from rdkit import Chem from rdkit.Chem import AllChem @@ -5,9 +6,14 @@ from joblib import Parallel, delayed from pprint import pprint +from groupy.chem import ensure_mol +from groupy.exceptions import InvalidSmilesError from groupy.gp_tool import Tool from groupy.io import write_text_lines +logger = logging.getLogger(__name__) +CONVERSION_EXCEPTIONS = (OSError, RuntimeError, StopIteration, ValueError) + def _load_pybel(): try: @@ -40,9 +46,10 @@ def smi_to_xyz(smi, xyz_path=None): :param xyz_path: str. Path of xyz file you want to generate. Default={smi}.xyz :return: bool. True if the xyz file is successfully generated. """ - mol = Chem.MolFromSmiles(smi) - if mol is None: - print(f'can not read {smi}, please check your SMILES') + try: + mol = ensure_mol(smi) + except InvalidSmilesError as exc: + logger.warning("Failed to parse SMILES %r for xyz conversion: %s", smi, exc) return False mol_with_h = Chem.AddHs(mol) @@ -58,21 +65,9 @@ def smi_to_xyz(smi, xyz_path=None): if mol.make3D() is None: opt = mol.write("mol") else: - print(f'Error! There is something wrong when converting {smi} to xyz file, please check it.') + logger.warning("OpenBabel failed to generate 3D coordinates for %r", smi) return False - # Windows 下pybel有问题 - # try: - # mol = pybel.readstring("smi", smi) - # mol.addh() - # if mol.make3D() is None: - # opt = mol.write("mol") - # else: - # return False - # except: - # AllChem.MMFFOptimizeMolecule(mol_with_h) - # opt = Chem.MolToMolBlock(mol_with_h) - if xyz_path is None: xyz_path = smi + '.xyz' with open(xyz_path, 'w') as file: @@ -176,8 +171,8 @@ def convert_file_type(in_format, in_path, out_format, out_path=None): :param out_format: str. Target format. :param out_path: str. Target file path. If set to None, out_path will be same as in_path except its suffix. """ + pybel = _load_pybel() try: - pybel = _load_pybel() mol = pybel.readfile(in_format, in_path).__next__() # print('The SMILES of this system is :') # print(mol.write('smi')) @@ -187,8 +182,8 @@ def convert_file_type(in_format, in_path, out_format, out_path=None): out_path = out_path[0] + '.' + out_format mol.write(out_format, out_path, overwrite=True) return None - except: - print(f'Error! There is something wrong when converting {in_path}, please check it.') + except CONVERSION_EXCEPTIONS as exc: + logger.warning("Failed to convert %s from %s to %s: %s", in_path, in_format, out_format, exc) return None def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_path=None): @@ -222,8 +217,8 @@ def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_ try: self.convert_file_type(in_format=in_format, in_path=in_file_path[index], out_format=out_format, out_path=out_file_path[index]) - except: - # print('Warning!!!') + except CONVERSION_EXCEPTIONS as exc: + logger.warning("Failed to convert %s: %s", in_file_path[index], exc) error_in_file_path.append(in_file_path[index]) # print('There may something wrong in {}, please check it carefully!'.format(in_file_path[index])) @@ -283,14 +278,14 @@ def file_to_smi(file_path, format=None): :param format: str. Format of the file. :return: SMILES """ + pybel = _load_pybel() try: - pybel = _load_pybel() atoms = next(pybel.readfile(format=format, filename=file_path)) smi = atoms.write(format='smi').split('\t')[0] # print(smi) return smi - except: - print('There may something wrong in {}, please check it carefully!'.format(file_path)) + except CONVERSION_EXCEPTIONS as exc: + logger.warning("Failed to read SMILES from %s as %s: %s", file_path, format, exc) return 'There may something wrong in {}, please check it carefully!'.format(file_path) def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None): @@ -319,8 +314,8 @@ def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None): for index in tqdm(range(len(in_file_path))): try: smi_list.append(self.file_to_smi(format=in_format, file_path=in_file_path[index])) - except: - # print('Warning!!!') + except CONVERSION_EXCEPTIONS as exc: + logger.warning("Failed to read SMILES from %s: %s", in_file_path[index], exc) error_in_file_path.append(in_file_path[index]) # print('There may something wrong in {}, please check it carefully!'.format(in_file_path[index])) diff --git a/groupy/gp_generator.py b/groupy/gp_generator.py index b219345..b4e9515 100644 --- a/groupy/gp_generator.py +++ b/groupy/gp_generator.py @@ -1,3 +1,4 @@ +import logging from rdkit import Chem import os from tqdm import tqdm @@ -5,10 +6,15 @@ import time import random +from groupy.chem import ensure_mol +from groupy.exceptions import ConversionError, InvalidSmilesError from groupy.gp_convertor import Convertor from groupy.gp_tool import Tool from groupy.io import write_text_lines +logger = logging.getLogger(__name__) +GENERATION_EXCEPTIONS = (ConversionError, InvalidSmilesError, TypeError, OSError, RuntimeError, ValueError) + class Generator: """ @@ -26,8 +32,7 @@ def calculate_charge(self, smi): :param smi: SMILES str or instance of rdkit.Chem.rdchem.Mol :return: (int, int). (net charge, all charge) """ - if isinstance(smi, str): - smi = Chem.MolFromSmiles(smi) + smi = ensure_mol(smi) smi = Chem.AddHs(smi) # add H net_charge = 0 all_charge = 0 @@ -49,8 +54,7 @@ def calculate_multiplicity(self, smi): :param smi: SMILES str or instance of rdkit.Chem.rdchem.Mol :return: (int, int). (net charge, all charge) """ - if isinstance(smi, str): - smi = Chem.MolFromSmiles(smi) + smi = ensure_mol(smi) net_charge, all_charge = self.calculate_charge(smi) alpha_minus_beta = all_charge % 2 multiplicity = alpha_minus_beta + 1 @@ -87,20 +91,23 @@ def smi_to_gjf(self, smi, nproc='12', mem='12GB', chk_path=None, gjf_path=None, # default task if gaussian_keywords is None: gaussian_keywords = '#p opt freq b3lyp/6-31g*' - # default charge and multiplicity - if charge_and_multiplicity is None: - charge_and_multiplicity = f'{self.calculate_charge(smi)[0]} {self.calculate_multiplicity(smi)}' # default other tasks if other_tasks is None: other_tasks = [ '#p m062x/def2tzvp geom=check', '#p m062x/def2tzvp scrf=solvent=water geom=check', ] + temp_xyz_path = None try: + # default charge and multiplicity + if charge_and_multiplicity is None: + charge_and_multiplicity = f'{self.calculate_charge(smi)[0]} {self.calculate_multiplicity(smi)}' + # read smi c = Convertor() temp_xyz_path = f'temp_{str(time.time()) + str(random.randint(0,1000000000000000000))}.xyz' - c.smi_to_xyz(smi=smi, xyz_path=temp_xyz_path) + if not c.smi_to_xyz(smi=smi, xyz_path=temp_xyz_path): + raise ConversionError(f'Failed to convert {smi} to xyz coordinates.') # write gjf # 判断是否存在同名gjf @@ -119,12 +126,13 @@ def smi_to_gjf(self, smi, nproc='12', mem='12GB', chk_path=None, gjf_path=None, note=smi, old_chk_path=chk_path, add_link1=True) self.write_gjf_blank_line(gjf_path=gjf_path, blank_line_number=2) - # 删除临时xyz文件 - os.remove(temp_xyz_path) return True - except: - print(f'Error! There is something wrong when converting {smi} to gjf file, please check it.') + except GENERATION_EXCEPTIONS as exc: + logger.warning("Failed to generate gjf for %r: %s", smi, exc) return False + finally: + if temp_xyz_path is not None and os.path.exists(temp_xyz_path): + os.remove(temp_xyz_path) def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, nproc='12', mem='12GB', diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1ca6705..215037a 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -286,6 +286,36 @@ def block_openbabel(name, globals=None, locals=None, fromlist=(), level=0): finally: builtins.__import__ = original_import + def test_convertor_reports_invalid_smiles_for_xyz(self): + from groupy.gp_convertor import Convertor + + with tempfile.TemporaryDirectory() as tmpdir: + xyz_path = Path(tmpdir) / "invalid.xyz" + + with self.assertLogs("groupy.gp_convertor", level="WARNING") as log_context: + result = Convertor.smi_to_xyz("not-a-smiles", str(xyz_path)) + + self.assertFalse(result) + self.assertFalse(xyz_path.exists()) + self.assertIn("Failed to parse SMILES", log_context.output[0]) + + def test_convert_file_type_surfaces_missing_openbabel_hint(self): + from groupy.gp_convertor import Convertor + + original_import = builtins.__import__ + + def block_openbabel(name, globals=None, locals=None, fromlist=(), level=0): + if name == "openbabel": + raise ImportError("blocked for test") + return original_import(name, globals, locals, fromlist, level) + + builtins.__import__ = block_openbabel + try: + with self.assertRaisesRegex(ImportError, "conda install -c conda-forge openbabel"): + Convertor.convert_file_type("xyz", "missing.xyz", "mol") + finally: + builtins.__import__ = original_import + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_convertor") def test_batch_smi_to_xyz_does_not_write_logs_by_default(self): from groupy.gp_convertor import Convertor @@ -386,6 +416,42 @@ def fake_batch_file_to_smi_mpi(*args, **kwargs): class GeneratorSmokeTests(unittest.TestCase): + def test_smi_to_gjf_returns_false_when_xyz_conversion_fails(self): + from groupy import gp_generator + from groupy.gp_generator import Generator + + original_convertor = gp_generator.Convertor + + class FailingConvertor: + def smi_to_xyz(self, smi, xyz_path=None): + return False + + gp_generator.Convertor = FailingConvertor + try: + with tempfile.TemporaryDirectory() as tmpdir: + gjf_path = Path(tmpdir) / "molecule.gjf" + + with self.assertLogs("groupy.gp_generator", level="WARNING") as log_context: + result = Generator().smi_to_gjf( + "C1CCCC1", + gjf_path=str(gjf_path), + chk_path=str(Path(tmpdir) / "molecule.chk"), + charge_and_multiplicity="0 1", + ) + + self.assertFalse(result) + self.assertFalse(gjf_path.exists()) + self.assertIn("Failed to generate gjf", log_context.output[0]) + finally: + gp_generator.Convertor = original_convertor + + def test_generator_rejects_invalid_smiles_for_charge(self): + from groupy.exceptions import InvalidSmilesError + from groupy.gp_generator import Generator + + with self.assertRaises(InvalidSmilesError): + Generator().calculate_charge("not-a-smiles") + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_generator") def test_batch_smi_to_gjf_does_not_write_logs_by_default(self): from groupy.gp_generator import Generator From 36f010eb5aaf75f3efff562ad7debe5f1728ae0b Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:51:21 +0800 Subject: [PATCH 15/29] Add quiet mode for core batch APIs --- REFACTORING_PLAN.md | 5 +++- groupy/gp_calculator.py | 55 ++++++++++++++++++++++++++++------------- groupy/gp_counter.py | 36 +++++++++++++++++---------- tests/test_smoke.py | 46 ++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 31 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 23016d1..b9a2b27 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -122,6 +122,8 @@ Current file handling status: - [x] `Generator.smi_to_gjf()` now catches explicit generation/input exceptions. - [x] Replace `raise NotImplemented(...)` with `NotImplementedError` or `ValueError`. - [ ] Move user-facing `print()` calls toward the CLI layer. + - [x] Core `Calculator.calculate_mols*()` batch methods now support `verbose=False`. + - [x] Core `Counter.count_mols*()` batch methods now support `verbose=False`. - [ ] Use `logging` in library code. - [x] Use module loggers for invalid SMILES in core calculator/counter paths. - [x] Use module loggers for conversion and Gaussian input generation failures. @@ -140,6 +142,7 @@ Current exception/error reporting status: - Unexpected bugs in core calculation/counting are no longer hidden by bare `except:` blocks. - Conversion and Gaussian generation no longer hide missing OpenBabel install errors. - `Generator.smi_to_gjf()` cleans temporary xyz files in a `finally` block. +- Core batch calculation/counting methods keep legacy progress output by default, but `verbose=False` suppresses `print()` and `tqdm` output for GUI/programmatic use. ## Phase 6: Dependency Boundaries And Lazy Imports @@ -233,5 +236,5 @@ Current parallel API status: Continue with Phase 7: 1. Continue replacing broad exception handling in conversion/generation and legacy interactive paths. -2. Move user-facing `print()` calls out of library batch methods where practical. +2. Move user-facing `print()` calls out of conversion/generation batch methods where practical. 3. After the core API is stable enough, begin the minimal PySide6 GUI shell. diff --git a/groupy/gp_calculator.py b/groupy/gp_calculator.py index 42880d7..21250a0 100644 --- a/groupy/gp_calculator.py +++ b/groupy/gp_calculator.py @@ -14,6 +14,12 @@ logger = logging.getLogger(__name__) +def _report_progress(message, verbose): + logger.info(message) + if verbose: + print(message) + + def _failed_calculation_result(smiles, error): return {'smiles': smiles, 'molar_mass': '?', @@ -439,7 +445,8 @@ def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=Tru return _failed_calculation_result(init_smi, str(exc)) def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.csv', - check_hydrocarbon=True, parameter_type='step_wise', error_file_path=None): + check_hydrocarbon=True, parameter_type='step_wise', + error_file_path=None, verbose=True): """ Calculating properties of a batch of molecules. :param smiles_file_path: path of the file(.txt, .xlsx, .csv) in which saved SMILES. @@ -448,34 +455,42 @@ def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.cs If the molecule is not hydrocarbon, Calculator will not calculate delta_Hc, q and ISP. If set to False, Calculator will calculate these properties no matter whether the molecule is hydrocarbon. Default=True. :param error_file_path: optional path for writing SMILES strings that failed during batch calculation. + :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. :return: instance of pandas.DataFrame """ - print('reading input file...') + _report_progress('reading input file...', verbose) try: smiles_iterator = load_smiles_file(smiles_file_path) except ValueError: - print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') + logger.warning('Unable to read SMILES input file %s; expected .txt, .xlsx, or .csv.', smiles_file_path) + if verbose: + print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') return None mol_number = len(smiles_iterator) - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - print('start calculating...') + _report_progress( + 'reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number), + verbose, + ) + _report_progress('start calculating...', verbose) properties_dict_list = [] error_smi = [] - for i in tqdm(smiles_iterator): + for i in tqdm(smiles_iterator, disable=not verbose): result = self.calculate_a_mol(i, check_hydrocarbon=check_hydrocarbon, parameter_type=parameter_type) properties_dict_list.append(result) if result.get('error'): error_smi.append(i) - print('calculation completed!') - print('start to export result to {} ...'.format(properties_file_path)) + _report_progress('calculation completed!', verbose) + _report_progress('start to export result to {} ...'.format(properties_file_path), verbose) result = pd.DataFrame(properties_dict_list) result.to_csv(properties_file_path, index_label='index') if error_file_path is not None: write_text_lines(error_smi, error_file_path) - print('Done!') + _report_progress('Done!', verbose) return result - def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_result_mpi.csv', check_hydrocarbon=True, parameter_type='step_wise', n_jobs=1, batch_size='auto'): + def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_result_mpi.csv', + check_hydrocarbon=True, parameter_type='step_wise', + n_jobs=1, batch_size='auto', verbose=True): """ Calculating properties of a batch of molecules with MPI acceleration. :param smiles_file_path: path of the file(.txt, .xlsx, .csv) in which saved SMILES. @@ -485,24 +500,30 @@ def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_resul Default=True. :param n_jobs: int. Number of cores. Default=1 :param batch_size: int. Task number per core. default='auto' + :param verbose: if True, print progress messages. Set False for programmatic or GUI use. :return: instance of pandas.DataFrame """ - print('reading input file...') + _report_progress('reading input file...', verbose) try: smiles_iterator = load_smiles_file(smiles_file_path) except ValueError: - print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') + logger.warning('Unable to read SMILES input file %s; expected .txt, .xlsx, or .csv.', smiles_file_path) + if verbose: + print('无法识别的文件类型,请以.txt/.xlsx/.csv类型的文件作为输入。') return None mol_number = len(smiles_iterator) - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) - print('start calculating...') + _report_progress( + 'reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number), + verbose, + ) + _report_progress('start calculating...', verbose) task = [delayed(self.calculate_a_mol)(i, parameter_type=parameter_type, check_hydrocarbon=check_hydrocarbon) for i in smiles_iterator] properties_dict_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('calculation completed!') - print('start to export result to {} ...'.format(properties_file_path)) + _report_progress('calculation completed!', verbose) + _report_progress('start to export result to {} ...'.format(properties_file_path), verbose) result = pd.DataFrame(properties_dict_list) result.to_csv(properties_file_path, index_label='index') - print('Done!') + _report_progress('Done!', verbose) return result def calculate_mols_parallel(self, *args, **kwargs): diff --git a/groupy/gp_counter.py b/groupy/gp_counter.py index 2309544..595bd56 100644 --- a/groupy/gp_counter.py +++ b/groupy/gp_counter.py @@ -15,6 +15,12 @@ logger = logging.getLogger(__name__) +def _report_progress(message, verbose): + logger.info(message) + if verbose: + print(message) + + # tool def has_non_aromatic_neighbor(atom): """判断原子周围是否有非芳香原子""" @@ -3261,16 +3267,18 @@ def count_a_mol(self, mol, clear_mode=False, add_note=False, add_smiles=False): logger.warning("Failed to count groups for %r: %s", init_smi, exc) return self.init_result.copy() - def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv', add_note=False, add_smiles=False): + def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv', + add_note=False, add_smiles=False, verbose=True): """ Counting number of different groups of a batch of molecules. :param smiles_file_path: str. Path of the file(.txt, .xlsx, .csv) in which saved SMILES of molecules. :param count_result_file_path: str. path of result file. Default='count_result.csv'. :param add_note: bool. If set to True, a note(SMILES of current molecule) will be added to the dictionary that stores the results, i.e. {note: SMILES}. Default=False. :param add_smiles: bool. If set to True, The SMILES of current molecule will be added to the dictionary that stores the results, i.e. {smiles: SMILES}. Default=False. + :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. :return: pandas Dataframe. A dictionary that stores the results. """ - print('reading the input file...') + _report_progress('reading the input file...', verbose) try: smiles_iterator = load_smiles_file(smiles_file_path) except ValueError as exc: @@ -3278,18 +3286,19 @@ def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv' 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') from exc mol_number = len(smiles_iterator) - print('Done, totally detected {} molecules, start counting...'.format(mol_number)) + _report_progress('Done, totally detected {} molecules, start counting...'.format(mol_number), verbose) count_result_dict_list = [] - for i in tqdm(smiles_iterator): + for i in tqdm(smiles_iterator, disable=not verbose): count_result_dict_list.append(self.count_a_mol(i, add_note=add_note, add_smiles=add_smiles)) - print('Done!') - print('writing to csv...') + _report_progress('Done!', verbose) + _report_progress('writing to csv...', verbose) result = pd.DataFrame(count_result_dict_list) result.to_csv(count_result_file_path, index_label='index') - print('Done!') + _report_progress('Done!', verbose) return result - def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result.csv', add_note=False, add_smiles=False, n_jobs=1, batch_size='auto'): + def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result.csv', + add_note=False, add_smiles=False, n_jobs=1, batch_size='auto', verbose=True): """ Counting number of different groups of a batch of molecules with MPI acceleration. :param smiles_file_path: str. Path of the file(.txt, .xlsx, .csv) in which saved SMILES of molecules. @@ -3298,9 +3307,10 @@ def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result. :param add_smiles: bool. If set to True, The SMILES of current molecule will be added to the dictionary that stores the results, i.e. {smiles: SMILES}. Default=False. :param n_jobs: int. number of CPU cores you want to use when counting groups. :param batch_size: int or str. Number of tasks per CPU core you want to use when counting groups. Default='auto'. + :param verbose: if True, print progress messages. Set False for programmatic or GUI use. :return: pandas Dataframe. A dictionary that stores the results. """ - print('reading the input file...') + _report_progress('reading the input file...', verbose) try: smiles_iterator = load_smiles_file(smiles_file_path) except ValueError as exc: @@ -3308,14 +3318,14 @@ def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result. 'ERROR: The file type cannot be read, use the.txt/.xlsx/.csv file as the input file.') from exc mol_number = len(smiles_iterator) - print('Done, totally detected {} molecules, start counting...'.format(mol_number)) + _report_progress('Done, totally detected {} molecules, start counting...'.format(mol_number), verbose) task = [delayed(self.count_a_mol)(i, add_note=add_note, add_smiles=add_smiles) for i in smiles_iterator] count_result_dict_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('Done!') - print('writing to csv...') + _report_progress('Done!', verbose) + _report_progress('writing to csv...', verbose) result = pd.DataFrame(count_result_dict_list) result.to_csv(count_result_file_path, index_label='index') - print('Done!') + _report_progress('Done!', verbose) return result def count_mols_parallel(self, *args, **kwargs): diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 215037a..4494f61 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,11 +1,13 @@ import importlib.util import builtins +import io import json import os import subprocess import sys import tempfile import unittest +from contextlib import redirect_stderr, redirect_stdout from pathlib import Path import pandas as pd @@ -154,6 +156,28 @@ def test_counter_batch_uses_shared_smiles_loader(self): self.assertEqual(result.loc[0, "f_168"], 5) self.assertTrue(output_path.exists()) + def test_counter_batch_can_run_quietly_for_gui_use(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + output_path = tmp_path / "count.csv" + input_path.write_text("C1CCCC1\n", encoding="utf-8") + stdout = io.StringIO() + stderr = io.StringIO() + + with redirect_stdout(stdout), redirect_stderr(stderr): + result = Counter().count_mols( + str(input_path), + count_result_file_path=str(output_path), + add_smiles=True, + verbose=False, + ) + + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "") + self.assertEqual(result.loc[0, "smiles"], "C1CCCC1") + self.assertTrue(output_path.exists()) + def test_calculator_batch_uses_shared_smiles_loader(self): with tempfile.TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) @@ -176,6 +200,28 @@ def test_calculator_batch_uses_shared_smiles_loader(self): self.assertTrue(output_path.exists()) self.assertFalse((tmp_path / "error.txt").exists()) + def test_calculator_batch_can_run_quietly_for_gui_use(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + output_path = tmp_path / "calculate.csv" + input_path.write_text("C1CCCC1\n", encoding="utf-8") + stdout = io.StringIO() + stderr = io.StringIO() + + with redirect_stdout(stdout), redirect_stderr(stderr): + result = Calculator().calculate_mols( + str(input_path), + properties_file_path=str(output_path), + verbose=False, + ) + + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "") + self.assertEqual(result.loc[0, "smiles"], "C1CCCC1") + self.assertAlmostEqual(result.loc[0, "molar_mass"], 70.135) + self.assertTrue(output_path.exists()) + def test_calculator_batch_writes_error_file_only_when_requested(self): with tempfile.TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) From d95dcbbceeb335aba558f61f4c0c6eafa5936d60 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 22:58:09 +0800 Subject: [PATCH 16/29] Add quiet mode for conversion batches --- REFACTORING_PLAN.md | 9 ++- groupy/gp_convertor.py | 121 ++++++++++++++++++++++++++++------------- groupy/gp_generator.py | 54 +++++++++++++----- tests/test_smoke.py | 112 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 55 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index b9a2b27..0c8eb6d 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -124,6 +124,8 @@ Current file handling status: - [ ] Move user-facing `print()` calls toward the CLI layer. - [x] Core `Calculator.calculate_mols*()` batch methods now support `verbose=False`. - [x] Core `Counter.count_mols*()` batch methods now support `verbose=False`. + - [x] Conversion `Convertor.batch_*()` methods now support `verbose=False`. + - [x] Gaussian input `Generator.batch_smi_to_gjf*()` methods now support `verbose=False`. - [ ] Use `logging` in library code. - [x] Use module loggers for invalid SMILES in core calculator/counter paths. - [x] Use module loggers for conversion and Gaussian input generation failures. @@ -143,6 +145,7 @@ Current exception/error reporting status: - Conversion and Gaussian generation no longer hide missing OpenBabel install errors. - `Generator.smi_to_gjf()` cleans temporary xyz files in a `finally` block. - Core batch calculation/counting methods keep legacy progress output by default, but `verbose=False` suppresses `print()` and `tqdm` output for GUI/programmatic use. +- Conversion and Gaussian generation batch methods also keep legacy progress output by default, with `verbose=False` available for GUI/programmatic use. ## Phase 6: Dependency Boundaries And Lazy Imports @@ -235,6 +238,6 @@ Current parallel API status: Continue with Phase 7: -1. Continue replacing broad exception handling in conversion/generation and legacy interactive paths. -2. Move user-facing `print()` calls out of conversion/generation batch methods where practical. -3. After the core API is stable enough, begin the minimal PySide6 GUI shell. +1. Continue replacing broad exception handling and direct prints in the legacy interactive path. +2. Add README examples for `verbose=False` programmatic batch use. +3. Begin the minimal PySide6 GUI shell once PySide6 availability is confirmed. diff --git a/groupy/gp_convertor.py b/groupy/gp_convertor.py index deb14ab..f05d0a2 100644 --- a/groupy/gp_convertor.py +++ b/groupy/gp_convertor.py @@ -8,13 +8,18 @@ from groupy.chem import ensure_mol from groupy.exceptions import InvalidSmilesError -from groupy.gp_tool import Tool -from groupy.io import write_text_lines +from groupy.io import load_smiles_file, write_text_lines logger = logging.getLogger(__name__) CONVERSION_EXCEPTIONS = (OSError, RuntimeError, StopIteration, ValueError) +def _report_progress(message, verbose): + logger.info(message) + if verbose: + print(message) + + def _load_pybel(): try: from openbabel import pybel @@ -82,30 +87,39 @@ def smi_to_xyz(smi, xyz_path=None): file.write(i.split()[2] + '\n') return True - def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, fail_file_path=None, succeed_file_path=None): + def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, + fail_file_path=None, succeed_file_path=None, verbose=True): """ Converting a batch of SMILES to xyz files. :param smiles_file_path: str. Path of the file in which saved SMILES. :param xyz_root_path: str. The folder path where all generated xyz files are saved. :param fail_file_path: optional path for writing SMILES strings that failed to generate xyz files. :param succeed_file_path: optional path for writing SMILES strings that successfully generated xyz files. + :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. :return: None. """ - smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) + _report_progress('reading input file...', verbose) + smiles_iterator = load_smiles_file(smiles_file_path) mol_number = len(smiles_iterator) zfill_number = len(str(mol_number)) + 3 - print('reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number)) + _report_progress( + 'reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number), + verbose, + ) # make xyz_root_path if os.path.exists(xyz_root_path): - print('xyz_root_path "{}" has been detected!'.format(xyz_root_path)) + _report_progress('xyz_root_path "{}" has been detected!'.format(xyz_root_path), verbose) else: - print('xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path)) + _report_progress( + 'xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path), + verbose, + ) os.makedirs(xyz_root_path) # end succeed = [] fail = [] - for (index, smi) in tqdm(enumerate(smiles_iterator)): + for (index, smi) in tqdm(enumerate(smiles_iterator), disable=not verbose): smi = smi.strip() out_name = os.path.join(xyz_root_path, '{}.xyz'.format(str(index).zfill(zfill_number))) generate_success_flag = self.smi_to_xyz(smi=smi, xyz_path=out_name) @@ -121,36 +135,47 @@ def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, fail_file_path=None, write_text_lines(succeed, succeed_file_path) if len(fail) == 0: - print('done! all .xyz files has been saved in {}'.format(xyz_root_path)) + _report_progress('done! all .xyz files has been saved in {}'.format(xyz_root_path), verbose) else: - print('Warning! The following SMILES fail to generate .xyz, please check...sorry(OTZ)') - print(fail) + logger.warning("Failed to generate xyz files for SMILES: %s", fail) + if verbose: + print('Warning! The following SMILES fail to generate .xyz, please check...sorry(OTZ)') + print(fail) return None - def batch_smi_to_xyz_mpi(self, smiles_file_path, xyz_root_path, n_jobs=1, batch_size='auto'): + def batch_smi_to_xyz_mpi(self, smiles_file_path, xyz_root_path, + n_jobs=1, batch_size='auto', verbose=True): """ Converting a batch of SMILES to xyz files with MPI acceleration. :param smiles_file_path: str. Path of the file in which saved SMILES. :param xyz_root_path: str. The folder path where all generated xyz files are saved. :param n_jobs: int. number of CPU cores you want to use when generating xyz file. :param batch_size: int or str. Number of tasks per CPU core you want to use when generating xyz file. Default='auto'. + :param verbose: if True, print progress messages. Set False for programmatic or GUI use. :return: None. """ - smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) + _report_progress('reading input file...', verbose) + smiles_iterator = load_smiles_file(smiles_file_path) mol_number = len(smiles_iterator) zfill_number = len(str(mol_number)) + 3 - print('reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number)) + _report_progress( + 'reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number), + verbose, + ) # make xyz_root_path if os.path.exists(xyz_root_path): - print('xyz_root_path "{}" has been detected!'.format(xyz_root_path)) + _report_progress('xyz_root_path "{}" has been detected!'.format(xyz_root_path), verbose) else: - print('xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path)) + _report_progress( + 'xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path), + verbose, + ) os.makedirs(xyz_root_path) # end task = [delayed(self.smi_to_xyz)(smi=smi, xyz_path=os.path.join(xyz_root_path, '{}.xyz'.format(str(index).zfill(zfill_number)))) for (index, smi) in enumerate(smiles_iterator)] result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - print('done! all .xyz files has been saved in {}'.format(xyz_root_path)) + _report_progress('done! all .xyz files has been saved in {}'.format(xyz_root_path), verbose) return result def batch_smi_to_xyz_parallel(self, *args, **kwargs): @@ -186,22 +211,26 @@ def convert_file_type(in_format, in_path, out_format, out_path=None): logger.warning("Failed to convert %s from %s to %s: %s", in_path, in_format, out_format, exc) return None - def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_path=None): + def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_path=None, verbose=True): """ Converting format of a batch of files. :param in_format: str. Init format. :param in_root_path: str. Path of the folder in which save files that you want to change format. :param out_format: str. Target format. :param out_root_path: str. Path of the folder where all new files are saved. If set to None, out_root_path will be same as in_root_path. + :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. """ if out_root_path is None: out_root_path = in_root_path else: # make out_root_path if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) + _report_progress('out_root_path "{}" has been detected!'.format(out_root_path), verbose) else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) + _report_progress( + 'out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path), + verbose, + ) os.makedirs(out_root_path) # end in_file_names = os.listdir(in_root_path) @@ -213,7 +242,7 @@ def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_ out_file_path = [os.path.join(out_root_path, i) for i in out_file_names] error_in_file_path = [] - for index in tqdm(range(len(in_file_path))): + for index in tqdm(range(len(in_file_path)), disable=not verbose): try: self.convert_file_type(in_format=in_format, in_path=in_file_path[index], out_format=out_format, out_path=out_file_path[index]) @@ -224,12 +253,15 @@ def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_ # When there is something wrong, print some warning if len(error_in_file_path) > 0: - print('Warning!Warning!Warning!') - for i in error_in_file_path: - print('There may something wrong in {}, please check it carefully!'.format(i)) + logger.warning("Failed to convert files: %s", error_in_file_path) + if verbose: + print('Warning!Warning!Warning!') + for i in error_in_file_path: + print('There may something wrong in {}, please check it carefully!'.format(i)) return None - def batch_convert_file_type_mpi(self, in_format, in_root_path, out_format, out_root_path=None, n_jobs=1, batch_size='auto'): + def batch_convert_file_type_mpi(self, in_format, in_root_path, out_format, + out_root_path=None, n_jobs=1, batch_size='auto', verbose=True): """ Converting format of a batch of files with MPI acceleration. :param in_format: str. Init format. @@ -238,15 +270,19 @@ def batch_convert_file_type_mpi(self, in_format, in_root_path, out_format, out_r :param out_root_path: str. Path of the folder where all new files are saved. If set to None, out_root_path will be same as in_root_path. :param n_jobs: int. number of CPU cores you want to use when converting file format. :param batch_size: int or str. Number of tasks per CPU core you want to use when converting file format. Default='auto'. + :param verbose: if True, print progress messages. Set False for programmatic or GUI use. """ if out_root_path is None: out_root_path = in_root_path else: # make out_root_path if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) + _report_progress('out_root_path "{}" has been detected!'.format(out_root_path), verbose) else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) + _report_progress( + 'out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path), + verbose, + ) os.makedirs(out_root_path) # end in_file_names = os.listdir(in_root_path) @@ -288,22 +324,26 @@ def file_to_smi(file_path, format=None): logger.warning("Failed to read SMILES from %s as %s: %s", file_path, format, exc) return 'There may something wrong in {}, please check it carefully!'.format(file_path) - def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None): + def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None, verbose=True): """ Converting a batch of files into SMILES. :param in_format: str. Format of the file. :param in_root_path: str. Path of the folder in which save files that you want to convert to SMILES. :param out_root_path: str. Path of the folder in which save the file that save SMILES of molecules. If set to None, out_root_path will be same as in_root_path. + :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. """ if out_root_path is None: out_root_path = in_root_path else: # make out_root_path if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) + _report_progress('out_root_path "{}" has been detected!'.format(out_root_path), verbose) else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) + _report_progress( + 'out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path), + verbose, + ) os.makedirs(out_root_path) # end in_file_names = os.listdir(in_root_path) @@ -311,7 +351,7 @@ def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None): in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] error_in_file_path = [] smi_list = [] - for index in tqdm(range(len(in_file_path))): + for index in tqdm(range(len(in_file_path)), disable=not verbose): try: smi_list.append(self.file_to_smi(format=in_format, file_path=in_file_path[index])) except CONVERSION_EXCEPTIONS as exc: @@ -321,16 +361,19 @@ def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None): # When there is something wrong, print some warning if len(error_in_file_path) > 0: - print('Warning!Warning!Warning!') - for i in error_in_file_path: - print('There may something wrong in {}, please check it carefully!'.format(i)) + logger.warning("Failed to read SMILES from files: %s", error_in_file_path) + if verbose: + print('Warning!Warning!Warning!') + for i in error_in_file_path: + print('There may something wrong in {}, please check it carefully!'.format(i)) with open(os.path.join(out_root_path, 'SMILES.txt'), 'w') as f: for i in smi_list: f.write(i + '\n') return smi_list - def batch_file_to_smi_mpi(self, in_format, in_root_path, out_root_path=None, n_jobs=1, batch_size='auto'): + def batch_file_to_smi_mpi(self, in_format, in_root_path, out_root_path=None, + n_jobs=1, batch_size='auto', verbose=True): """ Converting a batch of files into SMILES with MPI acceleration. :param in_format: str. Format of the file. @@ -339,15 +382,19 @@ def batch_file_to_smi_mpi(self, in_format, in_root_path, out_root_path=None, n_j If set to None, out_root_path will be same as in_root_path. :param n_jobs: int. number of CPU cores you want to use when converting file to SMILES. :param batch_size: int or str. Number of tasks per CPU core you want to use when converting file to SMILES. Default='auto'. + :param verbose: if True, print progress messages. Set False for programmatic or GUI use. """ if out_root_path is None: out_root_path = in_root_path else: # make out_root_path if os.path.exists(out_root_path): - print('out_root_path "{}" has been detected!'.format(out_root_path)) + _report_progress('out_root_path "{}" has been detected!'.format(out_root_path), verbose) else: - print('out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path)) + _report_progress( + 'out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path), + verbose, + ) os.makedirs(out_root_path) # end in_file_names = os.listdir(in_root_path) diff --git a/groupy/gp_generator.py b/groupy/gp_generator.py index b4e9515..56624d5 100644 --- a/groupy/gp_generator.py +++ b/groupy/gp_generator.py @@ -9,13 +9,18 @@ from groupy.chem import ensure_mol from groupy.exceptions import ConversionError, InvalidSmilesError from groupy.gp_convertor import Convertor -from groupy.gp_tool import Tool -from groupy.io import write_text_lines +from groupy.io import load_smiles_file, write_text_lines logger = logging.getLogger(__name__) GENERATION_EXCEPTIONS = (ConversionError, InvalidSmilesError, TypeError, OSError, RuntimeError, ValueError) +def _report_progress(message, verbose): + logger.info(message) + if verbose: + print(message) + + class Generator: """ A Class for generating Gaussian Job File (gjf) @@ -140,6 +145,7 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, add_other_tasks=False, other_tasks: list = None, index_start=0, fail_file_path=None, succeed_file_path=None, + verbose=True, ): """ Generating some gjf files based on a file in which saved some SMILES. @@ -156,24 +162,32 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, '#p m062x/def2tzvp scrf=solvent=water geom=check',]. Note that this parameter will only be used if add_other_tasks=True. :param fail_file_path: optional path for writing SMILES strings that failed to generate gjf files. :param succeed_file_path: optional path for writing SMILES strings that successfully generated gjf files. + :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. :return: None """ - smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) + _report_progress('reading input file...', verbose) + smiles_iterator = load_smiles_file(smiles_file_path) mol_number = len(smiles_iterator) zfill_number = len(str(mol_number)) + 5 - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) + _report_progress( + 'reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number), + verbose, + ) # make gjf_root_path if os.path.exists(gjf_root_path): - print('gjf_root_path "{}" has been detected!'.format(gjf_root_path)) + _report_progress('gjf_root_path "{}" has been detected!'.format(gjf_root_path), verbose) else: - print('gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path)) + _report_progress( + 'gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path), + verbose, + ) os.makedirs(gjf_root_path) # end succeed = [] fail = [] - for (index, smi) in tqdm(enumerate(smiles_iterator)): + for (index, smi) in tqdm(enumerate(smiles_iterator), disable=not verbose): smi = smi.strip() index += index_start chk_path = '{}.chk'.format(str(index).zfill(zfill_number)) @@ -194,17 +208,19 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, if succeed_file_path is not None: write_text_lines(succeed, succeed_file_path) if len(fail) == 0: - print('done! all .gjf files has been saved in {}'.format(gjf_root_path)) + _report_progress('done! all .gjf files has been saved in {}'.format(gjf_root_path), verbose) else: - print('Warning! The following SMILES fail to generate .gjf, please check...sorry(OTZ)') - print(fail) + logger.warning("Failed to generate gjf files for SMILES: %s", fail) + if verbose: + print('Warning! The following SMILES fail to generate .gjf, please check...sorry(OTZ)') + print(fail) return None def batch_smi_to_gjf_mpi(self, smiles_file_path, gjf_root_path=None, nproc='12', mem='12GB', gaussian_keywords=None, charge_and_multiplicity=None, add_other_tasks=False, other_tasks: list = None, - n_jobs=1, batch_size='auto' + n_jobs=1, batch_size='auto', verbose=True ): """ Generating some gjf files based on a file in which saved some SMILES with MPI acceleration. @@ -221,18 +237,26 @@ def batch_smi_to_gjf_mpi(self, smiles_file_path, gjf_root_path=None, '#p m062x/def2tzvp scrf=solvent=water geom=check',]. Note that this parameter will only be used if add_other_tasks=True. :param n_jobs: int. number of CPU cores you want to use when generating gjf file. :param batch_size: int or str. Number of tasks per CPU core you want to use when generating gjf file. Default='auto'. + :param verbose: if True, print progress messages. Set False for programmatic or GUI use. :return: None """ - smiles_iterator = Tool.load_smiles_iterator(smiles_file_path=smiles_file_path) + _report_progress('reading input file...', verbose) + smiles_iterator = load_smiles_file(smiles_file_path) mol_number = len(smiles_iterator) zfill_number = len(str(mol_number)) + 5 - print('reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number)) + _report_progress( + 'reading completed,A total of {} molecules detected, start calculating properties...'.format(mol_number), + verbose, + ) # make gjf_root_path if os.path.exists(gjf_root_path): - print('gjf_root_path "{}" has been detected!'.format(gjf_root_path)) + _report_progress('gjf_root_path "{}" has been detected!'.format(gjf_root_path), verbose) else: - print('gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path)) + _report_progress( + 'gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path), + verbose, + ) os.makedirs(gjf_root_path) # task diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 4494f61..7bc9f0b 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -423,6 +423,92 @@ def fake_smi_to_xyz(smi, xyz_path=None): self.assertEqual(fail_path.read_text(encoding="utf-8"), "invalid\n") self.assertEqual(succeed_path.read_text(encoding="utf-8"), "C1CCCC1\n") + def test_batch_smi_to_xyz_can_run_quietly_for_gui_use(self): + from groupy.gp_convertor import Convertor + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + xyz_root = tmp_path / "xyz" + input_path.write_text("C1CCCC1\n", encoding="utf-8") + + convertor = Convertor() + + def fake_smi_to_xyz(smi, xyz_path=None): + Path(xyz_path).write_text("fake xyz\n", encoding="utf-8") + return True + + convertor.smi_to_xyz = fake_smi_to_xyz + stdout = io.StringIO() + stderr = io.StringIO() + + with redirect_stdout(stdout), redirect_stderr(stderr): + convertor.batch_smi_to_xyz(str(input_path), str(xyz_root), verbose=False) + + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "") + self.assertTrue((xyz_root / "0000.xyz").exists()) + + def test_batch_convert_file_type_can_run_quietly_for_gui_use(self): + from groupy.gp_convertor import Convertor + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_root = tmp_path / "input" + output_root = tmp_path / "output" + input_root.mkdir() + (input_root / "molecule.xyz").write_text("fake xyz\n", encoding="utf-8") + + convertor = Convertor() + + def fake_convert_file_type(in_format, in_path, out_format, out_path=None): + Path(out_path).write_text(f"fake {out_format}\n", encoding="utf-8") + + convertor.convert_file_type = fake_convert_file_type + stdout = io.StringIO() + stderr = io.StringIO() + + with redirect_stdout(stdout), redirect_stderr(stderr): + convertor.batch_convert_file_type( + "xyz", + str(input_root), + "mol", + str(output_root), + verbose=False, + ) + + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "") + self.assertTrue((output_root / "molecule.mol").exists()) + + def test_batch_file_to_smi_can_run_quietly_for_gui_use(self): + from groupy.gp_convertor import Convertor + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_root = tmp_path / "input" + output_root = tmp_path / "output" + input_root.mkdir() + (input_root / "molecule.mol").write_text("fake mol\n", encoding="utf-8") + + convertor = Convertor() + convertor.file_to_smi = lambda file_path, format=None: "C1CCCC1" + stdout = io.StringIO() + stderr = io.StringIO() + + with redirect_stdout(stdout), redirect_stderr(stderr): + result = convertor.batch_file_to_smi( + "mol", + str(input_root), + str(output_root), + verbose=False, + ) + + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "") + self.assertEqual(result, ["C1CCCC1"]) + self.assertEqual((output_root / "SMILES.txt").read_text(encoding="utf-8"), "C1CCCC1\n") + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_convertor") def test_convertor_parallel_aliases_call_legacy_methods(self): from groupy.gp_convertor import Convertor @@ -559,6 +645,32 @@ def fake_smi_to_gjf(smi, **kwargs): self.assertEqual(fail_path.read_text(encoding="utf-8"), "invalid\n") self.assertEqual(succeed_path.read_text(encoding="utf-8"), "C1CCCC1\n") + def test_batch_smi_to_gjf_can_run_quietly_for_gui_use(self): + from groupy.gp_generator import Generator + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + gjf_root = tmp_path / "gjf" + input_path.write_text("C1CCCC1\n", encoding="utf-8") + + generator = Generator() + + def fake_smi_to_gjf(smi, **kwargs): + Path(kwargs["gjf_path"]).write_text("fake gjf\n", encoding="utf-8") + return True + + generator.smi_to_gjf = fake_smi_to_gjf + stdout = io.StringIO() + stderr = io.StringIO() + + with redirect_stdout(stdout), redirect_stderr(stderr): + generator.batch_smi_to_gjf(str(input_path), str(gjf_root), verbose=False) + + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "") + self.assertTrue((gjf_root / "000000.gjf").exists()) + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by gp_generator") def test_generator_parallel_alias_calls_legacy_method(self): from groupy.gp_generator import Generator From 40381a69c5aaf714ea45669a83aa75b9874987a6 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 23:05:01 +0800 Subject: [PATCH 17/29] Add initial desktop GUI entry point --- README.md | 18 +++++ REFACTORING_PLAN.md | 34 +++++--- groupy/__init__.py | 1 + groupy/gui/__init__.py | 5 ++ groupy/gui/__main__.py | 6 ++ groupy/gui/app.py | 175 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 + tests/test_smoke.py | 45 +++++++++++ 8 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 groupy/gui/__init__.py create mode 100644 groupy/gui/__main__.py create mode 100644 groupy/gui/app.py diff --git a/README.md b/README.md index 17f7551..7d431d3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ Install visualization support when needed: `python -m pip install -e ".[viewer]"` +Install desktop GUI support when needed: + +`python -m pip install -e ".[gui]"` + Install conversion and Gaussian input generation support when needed: `conda install -c conda-forge openbabel` (**Do not** use `pip install openbabel`.) @@ -34,6 +38,20 @@ For non-interactive use, one can run commands such as: `Groupy calculate --smiles C1CCCC1` +To launch the desktop GUI after installing GUI support: + +`Groupy-GUI` + +For Python scripts or GUI integrations, use quiet batch calls: + +```python +from groupy.gp_calculator import Calculator +from groupy.gp_counter import Counter + +Calculator().calculate_mols("SMILES.txt", "calculate.csv", verbose=False) +Counter().count_mols("SMILES.txt", "count.csv", add_smiles=True, verbose=False) +``` + ### Manual and Documention The user manual is in the manual folder, and the API documentation can be found in the doc folder. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 0c8eb6d..c3f644f 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -199,8 +199,9 @@ Current parallel API status: - [ ] Update README with: - [x] editable install - [x] conda/OpenBabel note - - [ ] library API examples + - [x] library API examples - [x] CLI examples + - [x] GUI install and launch examples - [ ] Add a build check: - `python -m build` - [ ] Add GitHub Actions for: @@ -212,19 +213,28 @@ Current parallel API status: ## Phase 9: Desktop GUI And Application Packaging -- [ ] Choose GUI framework, currently recommended: PySide6. -- [ ] Add a minimal `groupy/gui/` package. -- [ ] Build first GUI screen for: - - SMILES input - - property calculation - - group counting - - CSV export +- [x] Choose GUI framework: PySide6. +- [x] Add a minimal `groupy/gui/` package. +- [x] Add a `.[gui]` optional dependency group for PySide6. +- [x] Add a `Groupy-GUI` entry point. +- [x] Build first GUI screen for: + - [x] SMILES input + - [x] property calculation + - [x] group counting + - [x] CSV export - [ ] Add background worker handling so long calculations do not freeze the UI. -- [ ] Add GUI smoke tests where practical. +- [x] Add GUI smoke tests where practical. - [ ] Add packaging script for Windows executable builds. - [ ] Test the packaged app on a clean Windows environment. - [ ] Document limitations around OpenBabel and optional conversion features. +Current GUI status: + +- PySide6 is not installed in the current `groupy_dev` environment. +- `python -m groupy.gui --check` and `Groupy-GUI --check` report a clear install hint when PySide6 is missing. +- `groupy.gui` imports without importing PySide6, so core CLI/API usage remains lightweight. +- The first GUI window is implemented but still needs PySide6 installed for runtime visual QA. + ## Current Known Review Findings - `Groupy.egg-info/`, `build/`, and `dist/` are tracked generated artifacts. @@ -238,6 +248,6 @@ Current parallel API status: Continue with Phase 7: -1. Continue replacing broad exception handling and direct prints in the legacy interactive path. -2. Add README examples for `verbose=False` programmatic batch use. -3. Begin the minimal PySide6 GUI shell once PySide6 availability is confirmed. +1. Install or otherwise provide PySide6, then visually smoke-test `Groupy-GUI`. +2. Add background worker handling so long calculations do not freeze the UI. +3. Add a Windows packaging script for a double-clickable executable build. diff --git a/groupy/__init__.py b/groupy/__init__.py index e56d053..eb75328 100644 --- a/groupy/__init__.py +++ b/groupy/__init__.py @@ -11,6 +11,7 @@ "chem", "cli", "exceptions", + "gui", "io", "gp_loader", "gp_tool", diff --git a/groupy/gui/__init__.py b/groupy/gui/__init__.py new file mode 100644 index 0000000..86cd8be --- /dev/null +++ b/groupy/gui/__init__.py @@ -0,0 +1,5 @@ +"""Desktop GUI entry points for Groupy.""" + +from groupy.gui.app import INSTALL_HINT, is_pyside6_available, main + +__all__ = ["INSTALL_HINT", "is_pyside6_available", "main"] diff --git a/groupy/gui/__main__.py b/groupy/gui/__main__.py new file mode 100644 index 0000000..4b4dcb8 --- /dev/null +++ b/groupy/gui/__main__.py @@ -0,0 +1,6 @@ +"""Run the Groupy desktop GUI with `python -m groupy.gui`.""" + +from groupy.gui import main + + +raise SystemExit(main()) diff --git a/groupy/gui/app.py b/groupy/gui/app.py new file mode 100644 index 0000000..9825368 --- /dev/null +++ b/groupy/gui/app.py @@ -0,0 +1,175 @@ +"""Minimal PySide6 desktop application for Groupy.""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path +from typing import Any + +from groupy.api import calculate_smiles, count_smiles +from groupy.io import write_records_csv + +INSTALL_HINT = ( + "PySide6 is required for the Groupy desktop GUI. " + "Install it with `python -m pip install -e \".[gui]\"`." +) + + +def is_pyside6_available() -> bool: + """Return whether PySide6 can be imported in the current environment.""" + return importlib.util.find_spec("PySide6") is not None + + +def main(argv: list[str] | None = None) -> int: + """Run the desktop GUI.""" + parser = argparse.ArgumentParser(prog="Groupy-GUI", description="Launch the Groupy desktop GUI.") + parser.add_argument( + "--check", + action="store_true", + help="Check whether GUI dependencies are installed without launching a window.", + ) + args = parser.parse_args(argv) + + if not is_pyside6_available(): + print(INSTALL_HINT, file=sys.stderr) + return 1 + + if args.check: + print("PySide6 is available.") + return 0 + + from PySide6 import QtCore, QtWidgets + + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1]) + window = _create_main_window(QtCore, QtWidgets) + window.show() + return app.exec() + + +def _create_main_window(QtCore: Any, QtWidgets: Any): + class GroupyMainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self._records: list[dict[str, Any]] = [] + self.setWindowTitle("Groupy") + self.resize(980, 680) + + central = QtWidgets.QWidget() + self.setCentralWidget(central) + root_layout = QtWidgets.QVBoxLayout(central) + root_layout.setContentsMargins(14, 14, 14, 14) + root_layout.setSpacing(10) + + input_label = QtWidgets.QLabel("SMILES") + root_layout.addWidget(input_label) + + self.smiles_input = QtWidgets.QPlainTextEdit() + self.smiles_input.setPlaceholderText("Enter one SMILES per line") + self.smiles_input.setPlainText("C1CCCC1") + self.smiles_input.setMinimumHeight(96) + root_layout.addWidget(self.smiles_input) + + action_layout = QtWidgets.QHBoxLayout() + root_layout.addLayout(action_layout) + + self.calculate_button = QtWidgets.QPushButton("Calculate Properties") + self.calculate_button.clicked.connect(self._calculate_properties) + action_layout.addWidget(self.calculate_button) + + self.count_button = QtWidgets.QPushButton("Count Groups") + self.count_button.clicked.connect(self._count_groups) + action_layout.addWidget(self.count_button) + + self.export_button = QtWidgets.QPushButton("Export CSV") + self.export_button.setEnabled(False) + self.export_button.clicked.connect(self._export_csv) + action_layout.addWidget(self.export_button) + + action_layout.addStretch(1) + + self.result_table = QtWidgets.QTableWidget() + self.result_table.setAlternatingRowColors(True) + self.result_table.setSortingEnabled(True) + self.result_table.horizontalHeader().setStretchLastSection(True) + self.result_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + root_layout.addWidget(self.result_table, stretch=1) + + self.statusBar().showMessage("Ready") + + def _smiles_values(self) -> list[str]: + values = [line.strip() for line in self.smiles_input.toPlainText().splitlines() if line.strip()] + if not values: + raise ValueError("Enter at least one SMILES string.") + return values + + def _calculate_properties(self) -> None: + try: + records = [calculate_smiles(smiles) for smiles in self._smiles_values()] + except Exception as exc: + self._show_error(str(exc)) + return + self._show_records(records, "Calculated properties") + + def _count_groups(self) -> None: + try: + records = [count_smiles(smiles) for smiles in self._smiles_values()] + except Exception as exc: + self._show_error(str(exc)) + return + self._show_records(records, "Counted groups") + + def _show_records(self, records: list[dict[str, Any]], message: str) -> None: + self._records = records + columns = _record_columns(records) + self.result_table.setSortingEnabled(False) + self.result_table.clear() + self.result_table.setRowCount(len(records)) + self.result_table.setColumnCount(len(columns)) + self.result_table.setHorizontalHeaderLabels(columns) + + for row_index, record in enumerate(records): + for column_index, column in enumerate(columns): + value = record.get(column, "") + item = QtWidgets.QTableWidgetItem("" if value is None else str(value)) + item.setFlags(item.flags() & ~QtCore.Qt.ItemIsEditable) + self.result_table.setItem(row_index, column_index, item) + + self.result_table.resizeColumnsToContents() + self.result_table.setSortingEnabled(True) + self.export_button.setEnabled(bool(records)) + self.statusBar().showMessage(f"{message}: {len(records)} molecule(s)") + + def _export_csv(self) -> None: + if not self._records: + self._show_error("No results to export.") + return + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export CSV", + str(Path.cwd() / "groupy_results.csv"), + "CSV files (*.csv)", + ) + if not path: + return + write_records_csv(self._records, path) + self.statusBar().showMessage(f"Exported {path}") + + def _show_error(self, message: str) -> None: + QtWidgets.QMessageBox.warning(self, "Groupy", message) + self.statusBar().showMessage(message) + + return GroupyMainWindow() + + +def _record_columns(records: list[dict[str, Any]]) -> list[str]: + columns: list[str] = [] + for preferred in ("smiles", "note", "error"): + if any(preferred in record for record in records): + columns.append(preferred) + for record in records: + for key in record: + if key not in columns: + columns.append(key) + return columns diff --git a/pyproject.toml b/pyproject.toml index 01a6718..8a084b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ dependencies = [ [project.optional-dependencies] convert = [] +gui = [ + "PySide6", +] viewer = [ "ase", ] @@ -32,6 +35,7 @@ dev = [ [project.scripts] Groupy = "groupy.cli:main" +Groupy-GUI = "groupy.gui:main" [project.urls] Homepage = "https://github.com/47-5/Groupy" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 7bc9f0b..52147af 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -98,6 +98,7 @@ def test_optional_dependency_groups_keep_viewer_out_of_core(self): self.assertNotIn("ase", dependencies) self.assertEqual(optional_dependencies["viewer"], ["ase"]) + self.assertEqual(optional_dependencies["gui"], ["PySide6"]) self.assertEqual(optional_dependencies["convert"], []) self.assertIn("pytest", optional_dependencies["dev"]) @@ -729,6 +730,50 @@ def block_ase(name, globals=None, locals=None, fromlist=(), level=0): builtins.__import__ = original_import +class GuiSmokeTests(unittest.TestCase): + def test_gui_module_does_not_import_pyside6_at_import_time(self): + command = [ + sys.executable, + "-c", + "import sys; import groupy.gui; print('PySide6' in sys.modules)", + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stdout.strip(), "False") + + def test_gui_check_reports_dependency_status(self): + command = [ + sys.executable, + "-m", + "groupy.gui", + "--check", + ] + + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + if importlib.util.find_spec("PySide6"): + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("PySide6 is available.", completed.stdout) + else: + self.assertEqual(completed.returncode, 1) + self.assertIn("python -m pip install -e", completed.stderr) + self.assertIn(".[gui]", completed.stderr) + + class CliSmokeTests(unittest.TestCase): def test_cli_import_does_not_load_openbabel_conversion_stack(self): command = [ From a6b816eccd1bc19a8ab9fb7cad603e10c4b2075b Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 23:10:41 +0800 Subject: [PATCH 18/29] Record GUI smoke test result --- REFACTORING_PLAN.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index c3f644f..86d6251 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -222,6 +222,7 @@ Current parallel API status: - [x] property calculation - [x] group counting - [x] CSV export +- [x] Visually smoke-test the initial GUI runtime. - [ ] Add background worker handling so long calculations do not freeze the UI. - [x] Add GUI smoke tests where practical. - [ ] Add packaging script for Windows executable builds. @@ -233,7 +234,7 @@ Current GUI status: - PySide6 is not installed in the current `groupy_dev` environment. - `python -m groupy.gui --check` and `Groupy-GUI --check` report a clear install hint when PySide6 is missing. - `groupy.gui` imports without importing PySide6, so core CLI/API usage remains lightweight. -- The first GUI window is implemented but still needs PySide6 installed for runtime visual QA. +- The user confirmed the first GUI can run normally in a GUI-capable environment. ## Current Known Review Findings @@ -246,8 +247,8 @@ Current GUI status: ## Near-Term Next Step -Continue with Phase 7: +Continue with Phase 9: -1. Install or otherwise provide PySide6, then visually smoke-test `Groupy-GUI`. -2. Add background worker handling so long calculations do not freeze the UI. -3. Add a Windows packaging script for a double-clickable executable build. +1. Add background worker handling so long calculations do not freeze the UI. +2. Add a Windows packaging script for a double-clickable executable build. +3. Test the packaged app on a clean Windows environment. From 1934ee991f588903800b04e07683e7a42193ded7 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Wed, 24 Jun 2026 23:13:44 +0800 Subject: [PATCH 19/29] Run GUI calculations in a worker thread --- REFACTORING_PLAN.md | 6 ++-- groupy/gui/app.py | 88 ++++++++++++++++++++++++++++++++++++++++----- tests/test_smoke.py | 10 ++++++ 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 86d6251..02c3b9b 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -223,7 +223,7 @@ Current parallel API status: - [x] group counting - [x] CSV export - [x] Visually smoke-test the initial GUI runtime. -- [ ] Add background worker handling so long calculations do not freeze the UI. +- [x] Add background worker handling so long calculations do not freeze the UI. - [x] Add GUI smoke tests where practical. - [ ] Add packaging script for Windows executable builds. - [ ] Test the packaged app on a clean Windows environment. @@ -235,6 +235,8 @@ Current GUI status: - `python -m groupy.gui --check` and `Groupy-GUI --check` report a clear install hint when PySide6 is missing. - `groupy.gui` imports without importing PySide6, so core CLI/API usage remains lightweight. - The user confirmed the first GUI can run normally in a GUI-capable environment. +- Property calculation and group counting now run in a `QThread` worker instead of the GUI thread. +- Worker-based GUI behavior still needs visual confirmation in a PySide6 environment. ## Current Known Review Findings @@ -249,6 +251,6 @@ Current GUI status: Continue with Phase 9: -1. Add background worker handling so long calculations do not freeze the UI. +1. Visually confirm worker-based calculation/counting in a PySide6 environment. 2. Add a Windows packaging script for a double-clickable executable build. 3. Test the packaged app on a clean Windows environment. diff --git a/groupy/gui/app.py b/groupy/gui/app.py index 9825368..fe7a54a 100644 --- a/groupy/gui/app.py +++ b/groupy/gui/app.py @@ -22,6 +22,16 @@ def is_pyside6_available() -> bool: return importlib.util.find_spec("PySide6") is not None +def calculate_records(smiles_values: list[str]) -> list[dict[str, Any]]: + """Calculate properties for a list of SMILES strings.""" + return [calculate_smiles(smiles) for smiles in smiles_values] + + +def count_records(smiles_values: list[str]) -> list[dict[str, Any]]: + """Count groups for a list of SMILES strings.""" + return [count_smiles(smiles) for smiles in smiles_values] + + def main(argv: list[str] | None = None) -> int: """Run the desktop GUI.""" parser = argparse.ArgumentParser(prog="Groupy-GUI", description="Launch the Groupy desktop GUI.") @@ -49,10 +59,31 @@ def main(argv: list[str] | None = None) -> int: def _create_main_window(QtCore: Any, QtWidgets: Any): + class BatchWorker(QtCore.QObject): + finished = QtCore.Signal(object, str) + failed = QtCore.Signal(str) + + def __init__(self, smiles_values, operation, message): + super().__init__() + self._smiles_values = smiles_values + self._operation = operation + self._message = message + + @QtCore.Slot() + def run(self): + try: + records = self._operation(self._smiles_values) + except Exception as exc: + self.failed.emit(str(exc)) + return + self.finished.emit(records, self._message) + class GroupyMainWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self._records: list[dict[str, Any]] = [] + self._worker = None + self._worker_thread = None self.setWindowTitle("Groupy") self.resize(980, 680) @@ -105,20 +136,61 @@ def _smiles_values(self) -> list[str]: return values def _calculate_properties(self) -> None: - try: - records = [calculate_smiles(smiles) for smiles in self._smiles_values()] - except Exception as exc: - self._show_error(str(exc)) - return - self._show_records(records, "Calculated properties") + self._start_worker(calculate_records, "Calculated properties", "Calculating properties...") def _count_groups(self) -> None: + self._start_worker(count_records, "Counted groups", "Counting groups...") + + def _start_worker(self, operation, done_message: str, progress_message: str) -> None: + if self._worker_thread is not None: + return try: - records = [count_smiles(smiles) for smiles in self._smiles_values()] + smiles_values = self._smiles_values() except Exception as exc: self._show_error(str(exc)) return - self._show_records(records, "Counted groups") + + self._set_busy(True, progress_message) + thread = QtCore.QThread(self) + worker = BatchWorker(smiles_values, operation, done_message) + worker.moveToThread(thread) + + thread.started.connect(worker.run) + worker.finished.connect(self._finish_worker) + worker.failed.connect(self._fail_worker) + worker.finished.connect(worker.deleteLater) + worker.failed.connect(worker.deleteLater) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._clear_worker) + + self._worker = worker + self._worker_thread = thread + thread.start() + + def _finish_worker(self, records: list[dict[str, Any]], message: str) -> None: + self._set_busy(False) + self._show_records(records, message) + + def _fail_worker(self, message: str) -> None: + self._set_busy(False) + self._show_error(message) + + def _clear_worker(self) -> None: + self._worker = None + self._worker_thread = None + + def _set_busy(self, busy: bool, message: str | None = None) -> None: + self.calculate_button.setEnabled(not busy) + self.count_button.setEnabled(not busy) + self.smiles_input.setEnabled(not busy) + self.export_button.setEnabled((not busy) and bool(self._records)) + if busy: + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + self.statusBar().showMessage(message or "Working...") + else: + QtWidgets.QApplication.restoreOverrideCursor() def _show_records(self, records: list[dict[str, Any]], message: str) -> None: self._records = records diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 52147af..5e7a744 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -773,6 +773,16 @@ def test_gui_check_reports_dependency_status(self): self.assertIn("python -m pip install -e", completed.stderr) self.assertIn(".[gui]", completed.stderr) + def test_gui_record_helpers_use_public_api(self): + from groupy.gui.app import calculate_records, count_records + + calculation = calculate_records(["C1CCCC1"]) + counts = count_records(["C1CCCC1"]) + + self.assertEqual(calculation[0]["smiles"], "C1CCCC1") + self.assertAlmostEqual(calculation[0]["molar_mass"], 70.135) + self.assertEqual(counts[0], {"f_168": 5, "smiles": "C1CCCC1"}) + class CliSmokeTests(unittest.TestCase): def test_cli_import_does_not_load_openbabel_conversion_stack(self): From 755dff4d9b9a1ba7b5084c3c2bfbeaddb831193a Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 10:16:53 +0800 Subject: [PATCH 20/29] Add Windows GUI packaging script --- README.md | 10 +++ REFACTORING_PLAN.md | 6 +- pyproject.toml | 3 + scripts/build_windows_app.py | 121 +++++++++++++++++++++++++++++++++++ scripts/groupy_gui_entry.py | 6 ++ tests/test_smoke.py | 20 ++++++ 6 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 scripts/build_windows_app.py create mode 100644 scripts/groupy_gui_entry.py diff --git a/README.md b/README.md index 7d431d3..90e2cd7 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ Install desktop GUI support when needed: `python -m pip install -e ".[gui]"` +Install Windows app packaging support when needed: + +`python -m pip install -e ".[gui,package]"` + Install conversion and Gaussian input generation support when needed: `conda install -c conda-forge openbabel` (**Do not** use `pip install openbabel`.) @@ -42,6 +46,12 @@ To launch the desktop GUI after installing GUI support: `Groupy-GUI` +To build a double-clickable Windows app folder: + +`python scripts/build_windows_app.py` + +The default build output is `dist/Groupy/Groupy.exe`. Use `--mode onefile` to build a single executable. + For Python scripts or GUI integrations, use quiet batch calls: ```python diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 02c3b9b..ab20df0 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -225,7 +225,7 @@ Current parallel API status: - [x] Visually smoke-test the initial GUI runtime. - [x] Add background worker handling so long calculations do not freeze the UI. - [x] Add GUI smoke tests where practical. -- [ ] Add packaging script for Windows executable builds. +- [x] Add packaging script for Windows executable builds. - [ ] Test the packaged app on a clean Windows environment. - [ ] Document limitations around OpenBabel and optional conversion features. @@ -237,6 +237,8 @@ Current GUI status: - The user confirmed the first GUI can run normally in a GUI-capable environment. - Property calculation and group counting now run in a `QThread` worker instead of the GUI thread. - Worker-based GUI behavior still needs visual confirmation in a PySide6 environment. +- `scripts/build_windows_app.py` builds a PyInstaller app folder by default at `dist/Groupy/Groupy.exe`. +- The build script supports `--dry-run` for command inspection without requiring PyInstaller/PySide6. ## Current Known Review Findings @@ -252,5 +254,5 @@ Current GUI status: Continue with Phase 9: 1. Visually confirm worker-based calculation/counting in a PySide6 environment. -2. Add a Windows packaging script for a double-clickable executable build. +2. Run `python scripts/build_windows_app.py` in a `.[gui,package]` environment. 3. Test the packaged app on a clean Windows environment. diff --git a/pyproject.toml b/pyproject.toml index 8a084b1..a12e3d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ convert = [] gui = [ "PySide6", ] +package = [ + "PyInstaller", +] viewer = [ "ase", ] diff --git a/scripts/build_windows_app.py b/scripts/build_windows_app.py new file mode 100644 index 0000000..46ad803 --- /dev/null +++ b/scripts/build_windows_app.py @@ -0,0 +1,121 @@ +"""Build a double-clickable Windows desktop app for Groupy with PyInstaller.""" + +from __future__ import annotations + +import argparse +import importlib.util +import platform +import shlex +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ENTRY_SCRIPT = ROOT / "scripts" / "groupy_gui_entry.py" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--name", default="Groupy", help="Application name. Default: Groupy.") + parser.add_argument( + "--mode", + choices=("onedir", "onefile"), + default="onedir", + help="Build an app folder or a single executable. Default: onedir.", + ) + parser.add_argument("--dist-path", type=Path, default=ROOT / "dist", help="PyInstaller dist directory.") + parser.add_argument( + "--work-path", + type=Path, + default=ROOT / "build" / "pyinstaller", + help="PyInstaller temporary work directory.", + ) + parser.add_argument( + "--spec-path", + type=Path, + default=ROOT / "build" / "pyinstaller", + help="Directory for generated PyInstaller spec files.", + ) + parser.add_argument("--console", action="store_true", help="Keep a console window for debugging.") + parser.add_argument("--no-clean", action="store_true", help="Do not clear PyInstaller cache before building.") + parser.add_argument("--dry-run", action="store_true", help="Print the command without running PyInstaller.") + args = parser.parse_args(argv) + + command = build_command(args) + expected_output = expected_output_path(args) + + if args.dry_run: + print("PyInstaller command:") + print(format_command(command)) + print(f"Expected output: {expected_output}") + return 0 + + missing = [ + module + for module in ("PyInstaller", "PySide6") + if importlib.util.find_spec(module) is None + ] + if missing: + print( + "Missing build dependencies: {}. Install them with " + '`python -m pip install -e ".[gui,package]"`.'.format(", ".join(missing)), + file=sys.stderr, + ) + return 1 + + if platform.system() != "Windows": + print( + "Warning: PyInstaller builds for the current platform only. " + "Run this script on Windows to produce a Windows .exe.", + file=sys.stderr, + ) + + completed = subprocess.run(command, cwd=ROOT, check=False) + if completed.returncode != 0: + return completed.returncode + + print(f"Built Groupy desktop app: {expected_output}") + return 0 + + +def build_command(args: argparse.Namespace) -> list[str]: + command = [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + f"--name={args.name}", + f"--distpath={args.dist_path}", + f"--workpath={args.work_path}", + f"--specpath={args.spec_path}", + "--collect-data=groupy", + "--collect-data=rdkit", + "--collect-submodules=rdkit", + f"--{args.mode}", + ] + + if args.console: + command.append("--console") + else: + command.append("--windowed") + + if not args.no_clean: + command.append("--clean") + + command.append(str(ENTRY_SCRIPT)) + return command + + +def expected_output_path(args: argparse.Namespace) -> Path: + if args.mode == "onefile": + return args.dist_path / f"{args.name}.exe" + return args.dist_path / args.name / f"{args.name}.exe" + + +def format_command(command: list[str]) -> str: + return shlex.join(str(part) for part in command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/groupy_gui_entry.py b/scripts/groupy_gui_entry.py new file mode 100644 index 0000000..20ab179 --- /dev/null +++ b/scripts/groupy_gui_entry.py @@ -0,0 +1,6 @@ +"""PyInstaller entry script for the Groupy desktop GUI.""" + +from groupy.gui import main + + +raise SystemExit(main()) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5e7a744..0417e03 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -99,9 +99,29 @@ def test_optional_dependency_groups_keep_viewer_out_of_core(self): self.assertNotIn("ase", dependencies) self.assertEqual(optional_dependencies["viewer"], ["ase"]) self.assertEqual(optional_dependencies["gui"], ["PySide6"]) + self.assertEqual(optional_dependencies["package"], ["PyInstaller"]) self.assertEqual(optional_dependencies["convert"], []) self.assertIn("pytest", optional_dependencies["dev"]) + def test_windows_package_script_dry_run_uses_gui_entry(self): + script_path = Path(__file__).resolve().parents[1] / "scripts" / "build_windows_app.py" + completed = subprocess.run( + [sys.executable, str(script_path), "--dry-run"], + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("PyInstaller command:", completed.stdout) + self.assertIn("--windowed", completed.stdout) + self.assertIn("--onedir", completed.stdout) + self.assertIn("--collect-data=groupy", completed.stdout) + self.assertIn("--collect-submodules=rdkit", completed.stdout) + self.assertIn("groupy_gui_entry.py", completed.stdout) + self.assertIn("dist", completed.stdout) + class CoreChemistrySmokeTests(unittest.TestCase): def test_counter_counts_cyclopentane(self): From 8978b2458fef6010a22f67039f063b0b1b89e9fb Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 10:23:38 +0800 Subject: [PATCH 21/29] Record packaged app smoke test --- REFACTORING_PLAN.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index ab20df0..a772d20 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -226,6 +226,7 @@ Current parallel API status: - [x] Add background worker handling so long calculations do not freeze the UI. - [x] Add GUI smoke tests where practical. - [x] Add packaging script for Windows executable builds. +- [x] Test the packaged app can launch and run. - [ ] Test the packaged app on a clean Windows environment. - [ ] Document limitations around OpenBabel and optional conversion features. @@ -239,6 +240,8 @@ Current GUI status: - Worker-based GUI behavior still needs visual confirmation in a PySide6 environment. - `scripts/build_windows_app.py` builds a PyInstaller app folder by default at `dist/Groupy/Groupy.exe`. - The build script supports `--dry-run` for command inspection without requiring PyInstaller/PySide6. +- The user confirmed `dist/Groupy/Groupy.exe` can run normally. +- The generated `_internal` folder is currently large, about 753.5 MB, mainly because Intel MKL DLLs are bundled from the build environment. ## Current Known Review Findings @@ -253,6 +256,6 @@ Current GUI status: Continue with Phase 9: -1. Visually confirm worker-based calculation/counting in a PySide6 environment. -2. Run `python scripts/build_windows_app.py` in a `.[gui,package]` environment. -3. Test the packaged app on a clean Windows environment. +1. Reduce packaged app size by building from a lean conda-forge packaging environment and excluding unused modules where safe. +2. Test the packaged app on a clean Windows environment. +3. Document limitations around OpenBabel and optional conversion features. From 88fb938911658adbf3298722605e1fd7917cfc1a Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 10:40:47 +0800 Subject: [PATCH 22/29] Document packaged app release limits --- README.md | 18 ++++++++++++++++++ REFACTORING_PLAN.md | 12 ++++++++---- scripts/build_windows_app.py | 30 ++++++++++++++++++++++++++++++ tests/test_smoke.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 90e2cd7..69cef9a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,24 @@ To build a double-clickable Windows app folder: The default build output is `dist/Groupy/Groupy.exe`. Use `--mode onefile` to build a single executable. +For a smaller package, build from a clean packaging environment instead of a broad development environment: + +```powershell +conda create -n groupy_package -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib pyside6 pyinstaller +conda activate groupy_package +python -m pip install -e . --no-deps +python scripts\build_windows_app.py +``` + +The `_internal` folder contains bundled runtime libraries. Large MKL or BLAS DLLs usually come from the build environment and should not be deleted manually unless the packaged app is retested. + +The desktop app is intended for ordinary users who need SMILES-based property calculation, group counting, and CSV export. Optional workflows have separate dependency requirements: + +- Conversion and Gaussian input generation require OpenBabel from conda-forge and are not part of the default GUI workflow. +- Molecular visualization requires `.[viewer]` and ASE. +- A packaged Windows app should be tested on a clean Windows machine before distribution. +- Package size optimization is intentionally deferred until the user-facing workflow is stable, because removing runtime DLLs without retesting can break the executable. + For Python scripts or GUI integrations, use quiet batch calls: ```python diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index a772d20..47a8d44 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -228,7 +228,7 @@ Current parallel API status: - [x] Add packaging script for Windows executable builds. - [x] Test the packaged app can launch and run. - [ ] Test the packaged app on a clean Windows environment. -- [ ] Document limitations around OpenBabel and optional conversion features. +- [x] Document limitations around OpenBabel and optional conversion features. Current GUI status: @@ -242,6 +242,10 @@ Current GUI status: - The build script supports `--dry-run` for command inspection without requiring PyInstaller/PySide6. - The user confirmed `dist/Groupy/Groupy.exe` can run normally. - The generated `_internal` folder is currently large, about 753.5 MB, mainly because Intel MKL DLLs are bundled from the build environment. +- The build script now excludes clearly unused optional modules by default and supports `--no-default-excludes` / `--exclude-module` for tuning. +- README now recommends building from a clean conda-forge packaging environment before trying manual file exclusions. +- `_internal` size optimization is deferred until the user-facing workflow and release checklist are stable. +- README documents that OpenBabel conversion, Gaussian input generation, and ASE visualization remain optional workflows outside the default GUI workflow. ## Current Known Review Findings @@ -256,6 +260,6 @@ Current GUI status: Continue with Phase 9: -1. Reduce packaged app size by building from a lean conda-forge packaging environment and excluding unused modules where safe. -2. Test the packaged app on a clean Windows environment. -3. Document limitations around OpenBabel and optional conversion features. +1. Test the packaged app on a clean Windows environment. +2. Add a short release checklist for ordinary-user distribution. +3. Return to `_internal` size optimization after the release workflow is stable. diff --git a/scripts/build_windows_app.py b/scripts/build_windows_app.py index 46ad803..88e0823 100644 --- a/scripts/build_windows_app.py +++ b/scripts/build_windows_app.py @@ -13,6 +13,18 @@ ROOT = Path(__file__).resolve().parents[1] ENTRY_SCRIPT = ROOT / "scripts" / "groupy_gui_entry.py" +DEFAULT_EXCLUDES = [ + "IPython", + "PyQt5", + "PyQt6", + "PySide2", + "jupyter", + "matplotlib", + "notebook", + "pytest", + "sphinx", + "tkinter", +] def main(argv: list[str] | None = None) -> int: @@ -39,6 +51,17 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument("--console", action="store_true", help="Keep a console window for debugging.") parser.add_argument("--no-clean", action="store_true", help="Do not clear PyInstaller cache before building.") + parser.add_argument( + "--no-default-excludes", + action="store_true", + help="Do not exclude the default list of unused optional modules.", + ) + parser.add_argument( + "--exclude-module", + action="append", + default=[], + help="Additional module to exclude from the PyInstaller build. Can be used more than once.", + ) parser.add_argument("--dry-run", action="store_true", help="Print the command without running PyInstaller.") args = parser.parse_args(argv) @@ -95,6 +118,13 @@ def build_command(args: argparse.Namespace) -> list[str]: f"--{args.mode}", ] + excludes = [] + if not args.no_default_excludes: + excludes.extend(DEFAULT_EXCLUDES) + excludes.extend(args.exclude_module) + for module in excludes: + command.append(f"--exclude-module={module}") + if args.console: command.append("--console") else: diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0417e03..b43ba40 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -119,9 +119,41 @@ def test_windows_package_script_dry_run_uses_gui_entry(self): self.assertIn("--onedir", completed.stdout) self.assertIn("--collect-data=groupy", completed.stdout) self.assertIn("--collect-submodules=rdkit", completed.stdout) + self.assertIn("--exclude-module=matplotlib", completed.stdout) + self.assertIn("--exclude-module=IPython", completed.stdout) self.assertIn("groupy_gui_entry.py", completed.stdout) self.assertIn("dist", completed.stdout) + def test_windows_package_script_accepts_custom_excludes(self): + script_path = Path(__file__).resolve().parents[1] / "scripts" / "build_windows_app.py" + completed = subprocess.run( + [ + sys.executable, + str(script_path), + "--dry-run", + "--no-default-excludes", + "--exclude-module", + "example_unused_module", + ], + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("--exclude-module=example_unused_module", completed.stdout) + self.assertNotIn("--exclude-module=matplotlib", completed.stdout) + + def test_readme_documents_packaged_app_limitations(self): + readme_path = Path(__file__).resolve().parents[1] / "README.md" + readme = readme_path.read_text(encoding="utf-8") + + self.assertIn("OpenBabel from conda-forge", readme) + self.assertIn("not part of the default GUI workflow", readme) + self.assertIn("clean Windows machine", readme) + self.assertIn("Package size optimization is intentionally deferred", readme) + class CoreChemistrySmokeTests(unittest.TestCase): def test_counter_counts_cyclopentane(self): From 86ba786563d15a4881082d12546597647b29c0e3 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 10:45:11 +0800 Subject: [PATCH 23/29] Add packaged app release checklist --- README.md | 2 ++ REFACTORING_PLAN.md | 7 ++-- RELEASE_CHECKLIST.md | 86 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_smoke.py | 13 +++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 RELEASE_CHECKLIST.md diff --git a/README.md b/README.md index 69cef9a..bd04554 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ python scripts\build_windows_app.py The `_internal` folder contains bundled runtime libraries. Large MKL or BLAS DLLs usually come from the build environment and should not be deleted manually unless the packaged app is retested. +Before distributing a packaged app to ordinary users, follow `RELEASE_CHECKLIST.md`. + The desktop app is intended for ordinary users who need SMILES-based property calculation, group counting, and CSV export. Optional workflows have separate dependency requirements: - Conversion and Gaussian input generation require OpenBabel from conda-forge and are not part of the default GUI workflow. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 47a8d44..c02839c 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -202,6 +202,7 @@ Current parallel API status: - [x] library API examples - [x] CLI examples - [x] GUI install and launch examples + - [x] packaged app release checklist link - [ ] Add a build check: - `python -m build` - [ ] Add GitHub Actions for: @@ -229,6 +230,7 @@ Current parallel API status: - [x] Test the packaged app can launch and run. - [ ] Test the packaged app on a clean Windows environment. - [x] Document limitations around OpenBabel and optional conversion features. +- [x] Add a release checklist for ordinary-user distribution. Current GUI status: @@ -246,6 +248,7 @@ Current GUI status: - README now recommends building from a clean conda-forge packaging environment before trying manual file exclusions. - `_internal` size optimization is deferred until the user-facing workflow and release checklist are stable. - README documents that OpenBabel conversion, Gaussian input generation, and ASE visualization remain optional workflows outside the default GUI workflow. +- `RELEASE_CHECKLIST.md` captures the pre-release build checks, GUI smoke test, clean Windows validation, and packaged app distribution notes. ## Current Known Review Findings @@ -260,6 +263,6 @@ Current GUI status: Continue with Phase 9: -1. Test the packaged app on a clean Windows environment. -2. Add a short release checklist for ordinary-user distribution. +1. Run `RELEASE_CHECKLIST.md` on a clean Windows environment. +2. Record any clean Windows findings in the release notes or README. 3. Return to `_internal` size optimization after the release workflow is stable. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..1dffbce --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,86 @@ +# Groupy Release Checklist + +Use this checklist before distributing a Windows build to ordinary users. + +## 1. Build Environment + +- Build from a clean packaging environment, not a broad development environment. +- Prefer conda-forge packages for binary scientific dependencies. +- Install the local project without pulling duplicate dependencies: + +```powershell +conda create -n groupy_package -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib pyside6 pyinstaller +conda activate groupy_package +python -m pip install -e . --no-deps +``` + +## 2. Pre-Build Checks + +- Run the full smoke-test suite: + +```powershell +python -W error::ResourceWarning -m unittest discover -s tests +``` + +- Confirm the CLI still works: + +```powershell +Groupy.exe count --smiles C1CCCC1 +Groupy.exe calculate --smiles C1CCCC1 +Groupy-GUI.exe --check +``` + +- Confirm the current release version and user-facing limitations are documented. + +## 3. Build The App + +- Build the default Windows app folder: + +```powershell +python scripts\build_windows_app.py +``` + +- The expected app entry point is: + +```text +dist/Groupy/Groupy.exe +``` + +- Distribute the complete `dist/Groupy` folder. Do not move only `Groupy.exe` away from `_internal`. +- Do not delete DLLs from `_internal` unless the packaged app is retested afterward. + +## 4. GUI Smoke Test + +Run `dist/Groupy/Groupy.exe` and check: + +- The window opens by double-clicking. +- Property calculation works for `C1CCCC1`. +- Group counting works for `C1CCCC1`. +- CSV export creates a readable file. +- Invalid SMILES input produces an error row instead of crashing. +- Long-running calculation or counting does not freeze the UI. + +## 5. Optional Workflow Boundaries + +- The default GUI workflow covers SMILES-based property calculation, group counting, and CSV export. +- OpenBabel conversion and Gaussian input generation require OpenBabel from conda-forge. +- ASE visualization requires the viewer extra and is not part of the default GUI workflow. +- If optional workflows are added to a packaged app later, repeat the clean Windows validation. + +## 6. Clean Windows Validation + +Test the final artifact on a Windows machine without the development environment: + +- Unzip or copy the complete `dist/Groupy` folder. +- Launch `dist/Groupy/Groupy.exe` by double-clicking. +- Repeat the GUI smoke test. +- Test from a path with spaces, such as `C:\Users\\Desktop\Groupy Test`. +- Confirm CSV export works in a user-writable folder. +- Confirm the app still launches after a reboot. + +## 7. Release Artifact + +- Package the complete `dist/Groupy` folder as a zip archive. +- Include release notes that mention optional dependency boundaries. +- Record the build date, source commit, Python version, and build environment. +- Keep `_internal` size optimization as a separate post-release task until the main workflow is stable. diff --git a/tests/test_smoke.py b/tests/test_smoke.py index b43ba40..eeaa1ba 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -153,6 +153,19 @@ def test_readme_documents_packaged_app_limitations(self): self.assertIn("not part of the default GUI workflow", readme) self.assertIn("clean Windows machine", readme) self.assertIn("Package size optimization is intentionally deferred", readme) + self.assertIn("RELEASE_CHECKLIST.md", readme) + + def test_release_checklist_documents_double_click_distribution(self): + checklist_path = Path(__file__).resolve().parents[1] / "RELEASE_CHECKLIST.md" + checklist = checklist_path.read_text(encoding="utf-8") + + self.assertIn("dist/Groupy/Groupy.exe", checklist) + self.assertIn("dist/Groupy", checklist) + self.assertIn("_internal", checklist) + self.assertIn("clean Windows", checklist) + self.assertIn("C1CCCC1", checklist) + self.assertIn("CSV export", checklist) + self.assertIn("OpenBabel from conda-forge", checklist) class CoreChemistrySmokeTests(unittest.TestCase): From 1392ef4a6e6ae22682d96d6f4c24b0defb9b50a9 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 11:00:38 +0800 Subject: [PATCH 24/29] Finish remaining refactor checklist items --- .github/workflows/ci.yml | 39 ++++++ .gitignore | 1 + README.md | 8 +- REFACTORING_PLAN.md | 51 ++++---- groupy/api.py | 24 ++++ groupy/cli.py | 50 ++++++++ groupy/gp_calculator.py | 38 ++++-- groupy/gp_convertor.py | 249 ++++++++++++++++++++++++--------------- groupy/gp_counter.py | 40 +++++-- groupy/gp_generator.py | 69 +++++++---- groupy/gp_viewer.py | 35 ++++-- groupy/groupy_main.py | 12 +- tests/test_smoke.py | 148 ++++++++++++++++++++++- 13 files changed, 591 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6a5edc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install package + run: python -m pip install -e ".[dev]" + + - name: Run tests + run: python -W error::ResourceWarning -m unittest discover -s tests + + - name: Compile sources + run: python -m compileall groupy tests scripts + + - name: Build Python package + run: python -m build + + - name: Check Windows app build command + run: python scripts/build_windows_app.py --dry-run diff --git a/.gitignore b/.gitignore index 7b6caf3..5267b91 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ # Distribution / packaging .Python build/ +build_check_dist/ develop-eggs/ dist/ downloads/ diff --git a/README.md b/README.md index bd04554..e53635c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ For non-interactive use, one can run commands such as: `Groupy calculate --smiles C1CCCC1` +`Groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2` + To launch the desktop GUI after installing GUI support: `Groupy-GUI` @@ -82,8 +84,12 @@ Calculator().calculate_mols("SMILES.txt", "calculate.csv", verbose=False) Counter().count_mols("SMILES.txt", "count.csv", add_smiles=True, verbose=False) ``` +Batch APIs keep processing failed molecules by default for backward compatibility. Pass `continue_on_error=False` when a script or GUI workflow should stop at the first invalid input. + +CI runs the smoke tests, source compilation check, `python -m build`, and a dry-run Windows app packaging command. + ### Manual and Documention -The user manual is in the manual folder, and the API documentation can be found in the doc folder. +The user manual is in the manual folder. Generated API documentation can be found in the doc folder and should be kept separate from source documentation. # Known limitation diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index c02839c..00a6252 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -77,11 +77,11 @@ The current `groupy_main.py` is a fully interactive menu. Preserve it initially, - [x] Add `groupy/api.py` for non-interactive workflows shared by CLI and future GUI code. - [x] Move console entry point from `groupy.groupy_main:main` to the new CLI once ready. - [x] Preserve the old interactive menu as a subcommand or compatibility path. -- [ ] Add scriptable commands such as: +- [x] Add scriptable commands such as: - [x] `Groupy count --smiles C1CCCC1` - [x] `Groupy calculate --smiles C1CCCC1` - [x] `Groupy calculate --input SMILES.txt --output result.csv` - - `groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2` + - [x] `Groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2` - [x] Keep command-line parsing separate from chemistry logic. Current CLI behavior: @@ -92,18 +92,19 @@ Current CLI behavior: - `Groupy count --smiles C1CCCC1 --output count.csv` writes one-row CSV output. - `Groupy calculate --smiles C1CCCC1` prints calculated properties as JSON. - `Groupy calculate --input SMILES.txt --output calculate.csv` writes batch calculated properties to CSV. +- `Groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2` converts one molecular file through OpenBabel. ## Phase 4: File And Path Handling -- [ ] Replace scattered `os.path` logic with `pathlib.Path` where practical. +- [x] Replace scattered `os.path` logic with `pathlib.Path` where practical in active CLI/API, conversion, generation, viewer, and batch output paths. - [x] Centralize SMILES file loading into one helper. - [x] Replace `list(open(...))` with context-managed file reads for SMILES input. - [x] Use explicit encodings for text SMILES files. -- [ ] Avoid writing implicit side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` to the current working directory unless requested. +- [x] Avoid writing implicit side-effect files such as `error.txt`, `xyz_fail.txt`, and `gjf_fail.txt` to the current working directory unless requested. - [x] `Calculator.calculate_mols()` no longer writes `error.txt` unless `error_file_path` is provided. - [x] `Convertor.batch_smi_to_xyz()` no longer writes `xyz_fail.txt` or `xyz_succeed.txt` unless explicit paths are provided. - [x] `Generator.batch_smi_to_gjf()` no longer writes `gjf_fail.txt` or `gjf_succeed.txt` unless explicit paths are provided. -- [ ] Make batch output paths predictable and configurable. +- [x] Make batch output paths predictable and configurable. Current file handling status: @@ -112,25 +113,27 @@ Current file handling status: - `Calculator.calculate_mols()` now supports an explicit `error_file_path` for failed SMILES output. - `Convertor.batch_smi_to_xyz()` now supports explicit `fail_file_path` and `succeed_file_path` outputs. - `Generator.batch_smi_to_gjf()` now supports explicit `fail_file_path` and `succeed_file_path` outputs. +- Batch CSV and generated-file outputs create their parent directories when needed. +- Conversion batch methods process input files in sorted order for stable output names. ## Phase 5: Exceptions, Logging, And Error Reporting -- [ ] Replace bare `except:` blocks with specific exceptions. +- [x] Replace bare `except:` blocks with specific exceptions. - [x] `Calculator.calculate_a_mol()` now catches expected calculation/input errors explicitly. - [x] `Counter.count_a_mol()` now catches expected invalid-input errors explicitly. - [x] `Convertor` file conversion paths now catch explicit conversion exceptions. - [x] `Generator.smi_to_gjf()` now catches explicit generation/input exceptions. - [x] Replace `raise NotImplemented(...)` with `NotImplementedError` or `ValueError`. -- [ ] Move user-facing `print()` calls toward the CLI layer. +- [x] Move user-facing `print()` calls toward the CLI layer for non-interactive paths. - [x] Core `Calculator.calculate_mols*()` batch methods now support `verbose=False`. - [x] Core `Counter.count_mols*()` batch methods now support `verbose=False`. - [x] Conversion `Convertor.batch_*()` methods now support `verbose=False`. - [x] Gaussian input `Generator.batch_smi_to_gjf*()` methods now support `verbose=False`. -- [ ] Use `logging` in library code. +- [x] Use `logging` in library code. - [x] Use module loggers for invalid SMILES in core calculator/counter paths. - [x] Use module loggers for conversion and Gaussian input generation failures. -- [ ] For batch processing, continue processing failed molecules only when configured to do so. -- [ ] Preserve enough failure detail to debug invalid SMILES, unsupported formats, and dependency problems. +- [x] For batch processing, continue processing failed molecules only when configured to do so. +- [x] Preserve enough failure detail to debug invalid SMILES, unsupported formats, and dependency problems. - [x] Add `InvalidSmilesError` for failed SMILES parsing. - [x] Failed property calculation records now include an `error` field. - [x] Batch calculation writes requested error SMILES from structured failed records. @@ -146,6 +149,7 @@ Current exception/error reporting status: - `Generator.smi_to_gjf()` cleans temporary xyz files in a `finally` block. - Core batch calculation/counting methods keep legacy progress output by default, but `verbose=False` suppresses `print()` and `tqdm` output for GUI/programmatic use. - Conversion and Gaussian generation batch methods also keep legacy progress output by default, with `verbose=False` available for GUI/programmatic use. +- Batch calculation, counting, conversion, and Gaussian generation methods default to legacy `continue_on_error=True`, with `continue_on_error=False` available for workflows that should stop on the first failure. ## Phase 6: Dependency Boundaries And Lazy Imports @@ -174,7 +178,7 @@ Current dependency boundary status: - [x] Cache `Loader` results so Excel files are not repeatedly parsed. - [x] Add regression coverage around cached loader reads. -- [ ] Keep SMARTS/group-counting logic behaviorally unchanged until tests cover it better. +- [x] Keep SMARTS/group-counting logic behaviorally unchanged until tests cover it better. - [x] Rename or alias `*_mpi` APIs to `*_parallel`, since they use `joblib`, not MPI. - [x] Preserve old method names during transition for backward compatibility. @@ -196,21 +200,21 @@ Current parallel API status: ## Phase 8: Documentation And CI -- [ ] Update README with: +- [x] Update README with: - [x] editable install - [x] conda/OpenBabel note - [x] library API examples - [x] CLI examples - [x] GUI install and launch examples - [x] packaged app release checklist link -- [ ] Add a build check: +- [x] Add a build check: - `python -m build` -- [ ] Add GitHub Actions for: - - install - - lint - - tests - - package build -- [ ] Keep generated API documentation separate from source documentation. +- [x] Add GitHub Actions for: + - [x] install + - [x] lint + - [x] tests + - [x] package build +- [x] Keep generated API documentation separate from source documentation. ## Phase 9: Desktop GUI And Application Packaging @@ -252,12 +256,9 @@ Current GUI status: ## Current Known Review Findings -- `Groupy.egg-info/`, `build/`, and `dist/` are tracked generated artifacts. -- The command-line interface is tightly coupled to `input()` prompts. -- Importing the current CLI path can require OpenBabel even for workflows that do not use conversion. -- Several modules use bare `except:` and swallow useful debugging information. -- Some file reads use `list(open(...))` without context managers or explicit encoding. -- Batch workflows write failure logs to fixed filenames in the current working directory. +- Clean Windows validation still needs to be run outside the development machine. +- `_internal` size optimization is intentionally deferred until the release workflow is stable. +- The legacy interactive menu is still present for compatibility, but new automation should use `groupy.api`, `Groupy count`, `Groupy calculate`, or `Groupy convert`. ## Near-Term Next Step diff --git a/groupy/api.py b/groupy/api.py index 62e9e33..93c2589 100644 --- a/groupy/api.py +++ b/groupy/api.py @@ -3,8 +3,10 @@ from __future__ import annotations from collections.abc import Iterable +from pathlib import Path from typing import Any +from groupy.exceptions import ConversionError from groupy.gp_calculator import Calculator from groupy.gp_counter import Counter from groupy.io import load_smiles_file, write_records_csv @@ -79,3 +81,25 @@ def calculate_many_smiles( for smiles in smiles_values ] + +def convert_file( + input_path: str | Path, + from_format: str, + to_format: str, + output_path: str | Path | None = None, +) -> Path: + """Convert one molecular file and return the output path.""" + from groupy.gp_convertor import Convertor + + input_file = Path(input_path) + target_format = to_format.lstrip(".") + output_file = Path(output_path) if output_path is not None else input_file.with_suffix(f".{target_format}") + converted_path = Convertor.convert_file_type( + in_format=from_format.lstrip("."), + in_path=str(input_file), + out_format=target_format, + out_path=str(output_file), + ) + if converted_path is None or not output_file.exists(): + raise ConversionError(f"Failed to convert {input_file} to {output_file}.") + return Path(converted_path) diff --git a/groupy/cli.py b/groupy/cli.py index 8924a0f..576adbc 100644 --- a/groupy/cli.py +++ b/groupy/cli.py @@ -8,6 +8,7 @@ import argparse import json +import sys from pathlib import Path from typing import Sequence @@ -85,6 +86,34 @@ def build_parser() -> argparse.ArgumentParser: help="optional CSV path for saving the result", ) + convert_parser = subparsers.add_parser( + "convert", + help="convert one molecular file to another format", + ) + convert_parser.add_argument( + "--input", + type=Path, + required=True, + help="input molecule file", + ) + convert_parser.add_argument( + "--from", + dest="from_format", + required=True, + help="input file format, such as xyz or mol", + ) + convert_parser.add_argument( + "--to", + dest="to_format", + required=True, + help="output file format, such as mol2 or pdb", + ) + convert_parser.add_argument( + "--output", + type=Path, + help="optional output file path", + ) + return parser @@ -148,6 +177,25 @@ def _run_calculate(args: argparse.Namespace) -> int: return 0 +def _run_convert(args: argparse.Namespace) -> int: + from groupy.api import convert_file + from groupy.exceptions import ConversionError + + try: + output_path = convert_file( + args.input, + from_format=args.from_format, + to_format=args.to_format, + output_path=args.output, + ) + except (ConversionError, ImportError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + print(_format_json_output([{"output": str(output_path)}], single=True)) + return 0 + + def _format_json_output(records: list[dict], *, single: bool) -> str: payload = records[0] if single else records return json.dumps(payload, ensure_ascii=False, sort_keys=True) @@ -163,6 +211,8 @@ def main(argv: Sequence[str] | None = None) -> int: return _run_count(args) if args.command == "calculate": return _run_calculate(args) + if args.command == "convert": + return _run_convert(args) parser.error(f"Unsupported command: {args.command}") return 2 diff --git a/groupy/gp_calculator.py b/groupy/gp_calculator.py index 21250a0..24b4344 100644 --- a/groupy/gp_calculator.py +++ b/groupy/gp_calculator.py @@ -1,4 +1,5 @@ import logging +from pathlib import Path from rdkit import Chem from math import log import pandas as pd @@ -349,7 +350,8 @@ def is_hydrocarbon(self, mol): return False - def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=True, debug=False): + def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=True, debug=False, + raise_on_error=False): """ Calculating properties of a molecule. :param mol: instance of rdkit.Chem.rdchem.Mol or SMILES str which will be converter to rdkit.Chem.rdchem.Mol automatically. @@ -357,6 +359,7 @@ def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=Tru :param check_hydrocarbon: bool. Since Calculator.delta_Hc() was designed for hydrocarbon, if set to True, Calculator will check whether the molecule is hydrocarbon. If the molecule is not hydrocarbon, Calculator will not calculate delta_Hc, q and ISP. If set to False, Calculator will calculate these properties no matter whether the molecule is hydrocarbon. Default=True. + :param raise_on_error: if True, re-raise expected input/calculation errors instead of returning placeholders. :return: dict like {'smiles': init_smi, 'molar_mass': value, 'flash_point/K': value, @@ -441,12 +444,14 @@ def calculate_a_mol(self, mol, parameter_type='step_wise', check_hydrocarbon=Tru 'ISP': isp, 'note': counter_note + ' at 298K'} except (InvalidSmilesError, TypeError, ValueError, KeyError, AttributeError, ArithmeticError) as exc: + if raise_on_error: + raise logger.warning("Failed to calculate properties for %r: %s", init_smi, exc) return _failed_calculation_result(init_smi, str(exc)) def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.csv', check_hydrocarbon=True, parameter_type='step_wise', - error_file_path=None, verbose=True): + error_file_path=None, verbose=True, continue_on_error=True): """ Calculating properties of a batch of molecules. :param smiles_file_path: path of the file(.txt, .xlsx, .csv) in which saved SMILES. @@ -456,6 +461,7 @@ def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.cs Default=True. :param error_file_path: optional path for writing SMILES strings that failed during batch calculation. :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. + :param continue_on_error: if True, keep legacy batch behavior and write placeholder rows for failed SMILES. :return: instance of pandas.DataFrame """ _report_progress('reading input file...', verbose) @@ -475,14 +481,21 @@ def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.cs properties_dict_list = [] error_smi = [] for i in tqdm(smiles_iterator, disable=not verbose): - result = self.calculate_a_mol(i, check_hydrocarbon=check_hydrocarbon, parameter_type=parameter_type) + result = self.calculate_a_mol( + i, + check_hydrocarbon=check_hydrocarbon, + parameter_type=parameter_type, + raise_on_error=not continue_on_error, + ) properties_dict_list.append(result) if result.get('error'): error_smi.append(i) _report_progress('calculation completed!', verbose) _report_progress('start to export result to {} ...'.format(properties_file_path), verbose) result = pd.DataFrame(properties_dict_list) - result.to_csv(properties_file_path, index_label='index') + output_path = Path(properties_file_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + result.to_csv(output_path, index_label='index') if error_file_path is not None: write_text_lines(error_smi, error_file_path) _report_progress('Done!', verbose) @@ -490,7 +503,7 @@ def calculate_mols(self, smiles_file_path, properties_file_path='gp_3x_result.cs def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_result_mpi.csv', check_hydrocarbon=True, parameter_type='step_wise', - n_jobs=1, batch_size='auto', verbose=True): + n_jobs=1, batch_size='auto', verbose=True, continue_on_error=True): """ Calculating properties of a batch of molecules with MPI acceleration. :param smiles_file_path: path of the file(.txt, .xlsx, .csv) in which saved SMILES. @@ -501,6 +514,7 @@ def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_resul :param n_jobs: int. Number of cores. Default=1 :param batch_size: int. Task number per core. default='auto' :param verbose: if True, print progress messages. Set False for programmatic or GUI use. + :param continue_on_error: if True, keep legacy batch behavior and write placeholder rows for failed SMILES. :return: instance of pandas.DataFrame """ _report_progress('reading input file...', verbose) @@ -517,12 +531,22 @@ def calculate_mols_mpi(self, smiles_file_path, properties_file_path='gp_3x_resul verbose, ) _report_progress('start calculating...', verbose) - task = [delayed(self.calculate_a_mol)(i, parameter_type=parameter_type, check_hydrocarbon=check_hydrocarbon) for i in smiles_iterator] + task = [ + delayed(self.calculate_a_mol)( + i, + parameter_type=parameter_type, + check_hydrocarbon=check_hydrocarbon, + raise_on_error=not continue_on_error, + ) + for i in smiles_iterator + ] properties_dict_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) _report_progress('calculation completed!', verbose) _report_progress('start to export result to {} ...'.format(properties_file_path), verbose) result = pd.DataFrame(properties_dict_list) - result.to_csv(properties_file_path, index_label='index') + output_path = Path(properties_file_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + result.to_csv(output_path, index_label='index') _report_progress('Done!', verbose) return result diff --git a/groupy/gp_convertor.py b/groupy/gp_convertor.py index f05d0a2..4c36a60 100644 --- a/groupy/gp_convertor.py +++ b/groupy/gp_convertor.py @@ -1,5 +1,5 @@ import logging -import os +from pathlib import Path from rdkit import Chem from rdkit.Chem import AllChem from tqdm import tqdm @@ -7,7 +7,7 @@ from pprint import pprint from groupy.chem import ensure_mol -from groupy.exceptions import InvalidSmilesError +from groupy.exceptions import ConversionError, InvalidSmilesError from groupy.io import load_smiles_file, write_text_lines logger = logging.getLogger(__name__) @@ -75,7 +75,9 @@ def smi_to_xyz(smi, xyz_path=None): if xyz_path is None: xyz_path = smi + '.xyz' - with open(xyz_path, 'w') as file: + xyz_output_path = Path(xyz_path) + xyz_output_path.parent.mkdir(parents=True, exist_ok=True) + with xyz_output_path.open('w', encoding='utf-8') as file: file.write('{}\n'.format(atom_number)) file.write(smi + '\n') for index, i in enumerate(opt.splitlines()[4:]): @@ -88,7 +90,8 @@ def smi_to_xyz(smi, xyz_path=None): return True def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, - fail_file_path=None, succeed_file_path=None, verbose=True): + fail_file_path=None, succeed_file_path=None, verbose=True, + continue_on_error=True): """ Converting a batch of SMILES to xyz files. :param smiles_file_path: str. Path of the file in which saved SMILES. @@ -96,6 +99,7 @@ def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, :param fail_file_path: optional path for writing SMILES strings that failed to generate xyz files. :param succeed_file_path: optional path for writing SMILES strings that successfully generated xyz files. :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. + :param continue_on_error: if True, continue converting later SMILES after one conversion fails. :return: None. """ _report_progress('reading input file...', verbose) @@ -106,26 +110,31 @@ def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, 'reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number), verbose, ) - # make xyz_root_path - if os.path.exists(xyz_root_path): - _report_progress('xyz_root_path "{}" has been detected!'.format(xyz_root_path), verbose) + xyz_root = Path(xyz_root_path) + if xyz_root.exists(): + _report_progress('xyz_root_path "{}" has been detected!'.format(xyz_root), verbose) else: _report_progress( - 'xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path), + 'xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root), verbose, ) - os.makedirs(xyz_root_path) - # end + xyz_root.mkdir(parents=True, exist_ok=True) succeed = [] fail = [] for (index, smi) in tqdm(enumerate(smiles_iterator), disable=not verbose): smi = smi.strip() - out_name = os.path.join(xyz_root_path, '{}.xyz'.format(str(index).zfill(zfill_number))) - generate_success_flag = self.smi_to_xyz(smi=smi, xyz_path=out_name) + out_name = xyz_root / '{}.xyz'.format(str(index).zfill(zfill_number)) + generate_success_flag = self.smi_to_xyz(smi=smi, xyz_path=str(out_name)) if not generate_success_flag: fail.append(smi) + if not continue_on_error: + if fail_file_path is not None: + write_text_lines(fail, fail_file_path) + if succeed_file_path is not None: + write_text_lines(succeed, succeed_file_path) + raise ConversionError(f'Failed to generate xyz for {smi!r}.') else: succeed.append(smi) @@ -135,7 +144,7 @@ def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, write_text_lines(succeed, succeed_file_path) if len(fail) == 0: - _report_progress('done! all .xyz files has been saved in {}'.format(xyz_root_path), verbose) + _report_progress('done! all .xyz files has been saved in {}'.format(xyz_root), verbose) else: logger.warning("Failed to generate xyz files for SMILES: %s", fail) if verbose: @@ -144,7 +153,7 @@ def batch_smi_to_xyz(self, smiles_file_path, xyz_root_path, return None def batch_smi_to_xyz_mpi(self, smiles_file_path, xyz_root_path, - n_jobs=1, batch_size='auto', verbose=True): + n_jobs=1, batch_size='auto', verbose=True, continue_on_error=True): """ Converting a batch of SMILES to xyz files with MPI acceleration. :param smiles_file_path: str. Path of the file in which saved SMILES. @@ -152,6 +161,7 @@ def batch_smi_to_xyz_mpi(self, smiles_file_path, xyz_root_path, :param n_jobs: int. number of CPU cores you want to use when generating xyz file. :param batch_size: int or str. Number of tasks per CPU core you want to use when generating xyz file. Default='auto'. :param verbose: if True, print progress messages. Set False for programmatic or GUI use. + :param continue_on_error: if True, return all task results even when some conversions fail. :return: None. """ _report_progress('reading input file...', verbose) @@ -162,20 +172,27 @@ def batch_smi_to_xyz_mpi(self, smiles_file_path, xyz_root_path, 'reading completed,A total of {} molecules detected, start making xyz files...'.format(mol_number), verbose, ) - # make xyz_root_path - if os.path.exists(xyz_root_path): - _report_progress('xyz_root_path "{}" has been detected!'.format(xyz_root_path), verbose) + xyz_root = Path(xyz_root_path) + if xyz_root.exists(): + _report_progress('xyz_root_path "{}" has been detected!'.format(xyz_root), verbose) else: _report_progress( - 'xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root_path), + 'xyz_root_path "{}" has not been detected, I will create it for you'.format(xyz_root), verbose, ) - os.makedirs(xyz_root_path) - # end + xyz_root.mkdir(parents=True, exist_ok=True) - task = [delayed(self.smi_to_xyz)(smi=smi, xyz_path=os.path.join(xyz_root_path, '{}.xyz'.format(str(index).zfill(zfill_number)))) for (index, smi) in enumerate(smiles_iterator)] + task = [ + delayed(self.smi_to_xyz)( + smi=smi, + xyz_path=str(xyz_root / '{}.xyz'.format(str(index).zfill(zfill_number))), + ) + for (index, smi) in enumerate(smiles_iterator) + ] result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - _report_progress('done! all .xyz files has been saved in {}'.format(xyz_root_path), verbose) + if not continue_on_error and not all(result): + raise ConversionError('Failed to generate one or more xyz files.') + _report_progress('done! all .xyz files has been saved in {}'.format(xyz_root), verbose) return result def batch_smi_to_xyz_parallel(self, *args, **kwargs): @@ -198,20 +215,21 @@ def convert_file_type(in_format, in_path, out_format, out_path=None): """ pybel = _load_pybel() try: - mol = pybel.readfile(in_format, in_path).__next__() + input_path = Path(in_path) + mol = pybel.readfile(in_format, str(input_path)).__next__() # print('The SMILES of this system is :') # print(mol.write('smi')) - if out_path is None: - out_path = in_path.split('.') - out_path = out_path[0] + '.' + out_format - mol.write(out_format, out_path, overwrite=True) - return None + output_path = Path(out_path) if out_path is not None else input_path.with_suffix(f'.{out_format}') + output_path.parent.mkdir(parents=True, exist_ok=True) + mol.write(out_format, str(output_path), overwrite=True) + return str(output_path) except CONVERSION_EXCEPTIONS as exc: logger.warning("Failed to convert %s from %s to %s: %s", in_path, in_format, out_format, exc) return None - def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_path=None, verbose=True): + def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_path=None, verbose=True, + continue_on_error=True): """ Converting format of a batch of files. :param in_format: str. Init format. @@ -219,36 +237,42 @@ def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_ :param out_format: str. Target format. :param out_root_path: str. Path of the folder where all new files are saved. If set to None, out_root_path will be same as in_root_path. :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. + :param continue_on_error: if True, continue converting later files after one conversion fails. """ + input_root = Path(in_root_path) if out_root_path is None: - out_root_path = in_root_path + output_root = input_root else: - # make out_root_path - if os.path.exists(out_root_path): - _report_progress('out_root_path "{}" has been detected!'.format(out_root_path), verbose) + output_root = Path(out_root_path) + if output_root.exists(): + _report_progress('out_root_path "{}" has been detected!'.format(output_root), verbose) else: _report_progress( - 'out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path), + 'out_root_path "{}" has not been detected, I will create it for you'.format(output_root), verbose, ) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) + output_root.mkdir(parents=True, exist_ok=True) - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - out_file_names = [i.split('.')[0] + '.{}'.format(out_format) for i in in_file_names] - - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - out_file_path = [os.path.join(out_root_path, i) for i in out_file_names] + normalized_in_format = in_format.lower().lstrip('.') + normalized_out_format = out_format.lstrip('.') + in_file_path = sorted( + path for path in input_root.iterdir() + if path.is_file() and path.suffix.lower() == f'.{normalized_in_format}' + ) + out_file_path = [output_root / f'{path.stem}.{normalized_out_format}' for path in in_file_path] error_in_file_path = [] for index in tqdm(range(len(in_file_path)), disable=not verbose): - try: - self.convert_file_type(in_format=in_format, in_path=in_file_path[index], - out_format=out_format, out_path=out_file_path[index]) - except CONVERSION_EXCEPTIONS as exc: - logger.warning("Failed to convert %s: %s", in_file_path[index], exc) + converted_path = self.convert_file_type( + in_format=normalized_in_format, + in_path=str(in_file_path[index]), + out_format=normalized_out_format, + out_path=str(out_file_path[index]), + ) + if converted_path is None: error_in_file_path.append(in_file_path[index]) + if not continue_on_error: + raise ConversionError(f'Failed to convert {in_file_path[index]}.') # print('There may something wrong in {}, please check it carefully!'.format(in_file_path[index])) # When there is something wrong, print some warning @@ -261,7 +285,8 @@ def batch_convert_file_type(self, in_format, in_root_path, out_format, out_root_ return None def batch_convert_file_type_mpi(self, in_format, in_root_path, out_format, - out_root_path=None, n_jobs=1, batch_size='auto', verbose=True): + out_root_path=None, n_jobs=1, batch_size='auto', verbose=True, + continue_on_error=True): """ Converting format of a batch of files with MPI acceleration. :param in_format: str. Init format. @@ -271,30 +296,42 @@ def batch_convert_file_type_mpi(self, in_format, in_root_path, out_format, :param n_jobs: int. number of CPU cores you want to use when converting file format. :param batch_size: int or str. Number of tasks per CPU core you want to use when converting file format. Default='auto'. :param verbose: if True, print progress messages. Set False for programmatic or GUI use. + :param continue_on_error: if True, return all task results even when some conversions fail. """ + input_root = Path(in_root_path) if out_root_path is None: - out_root_path = in_root_path + output_root = input_root else: - # make out_root_path - if os.path.exists(out_root_path): - _report_progress('out_root_path "{}" has been detected!'.format(out_root_path), verbose) + output_root = Path(out_root_path) + if output_root.exists(): + _report_progress('out_root_path "{}" has been detected!'.format(output_root), verbose) else: _report_progress( - 'out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path), + 'out_root_path "{}" has not been detected, I will create it for you'.format(output_root), verbose, ) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - out_file_names = [i.split('.')[0] + '.{}'.format(out_format) for i in in_file_names] + output_root.mkdir(parents=True, exist_ok=True) - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] - out_file_path = [os.path.join(out_root_path, i) for i in out_file_names] - - task = [delayed(self.convert_file_type)(in_format=in_format, in_path=in_file_path[index],out_format=out_format, out_path=out_file_path[index]) for index in range(len(in_file_path))] + normalized_in_format = in_format.lower().lstrip('.') + normalized_out_format = out_format.lstrip('.') + in_file_path = sorted( + path for path in input_root.iterdir() + if path.is_file() and path.suffix.lower() == f'.{normalized_in_format}' + ) + out_file_path = [output_root / f'{path.stem}.{normalized_out_format}' for path in in_file_path] + + task = [ + delayed(self.convert_file_type)( + in_format=normalized_in_format, + in_path=str(in_file_path[index]), + out_format=normalized_out_format, + out_path=str(out_file_path[index]), + ) + for index in range(len(in_file_path)) + ] result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) + if not continue_on_error and any(item is None for item in result): + raise ConversionError('Failed to convert one or more files.') return result def batch_convert_file_type_parallel(self, *args, **kwargs): @@ -307,11 +344,12 @@ def batch_convert_file_type_parallel(self, *args, **kwargs): return self.batch_convert_file_type_mpi(*args, **kwargs) @staticmethod - def file_to_smi(file_path, format=None): + def file_to_smi(file_path, format=None, raise_on_error=False): """ Converting a file into SMILES. :param file_path: str. Path of the file. :param format: str. Format of the file. + :param raise_on_error: if True, raise ConversionError when the file cannot be read. :return: SMILES """ pybel = _load_pybel() @@ -321,10 +359,14 @@ def file_to_smi(file_path, format=None): # print(smi) return smi except CONVERSION_EXCEPTIONS as exc: + message = 'There may something wrong in {}, please check it carefully!'.format(file_path) logger.warning("Failed to read SMILES from %s as %s: %s", file_path, format, exc) - return 'There may something wrong in {}, please check it carefully!'.format(file_path) + if raise_on_error: + raise ConversionError(message) from exc + return message - def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None, verbose=True): + def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None, verbose=True, + continue_on_error=True): """ Converting a batch of files into SMILES. :param in_format: str. Format of the file. @@ -332,31 +374,42 @@ def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None, verbose :param out_root_path: str. Path of the folder in which save the file that save SMILES of molecules. If set to None, out_root_path will be same as in_root_path. :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. + :param continue_on_error: if True, continue converting later files after one conversion fails. """ + input_root = Path(in_root_path) if out_root_path is None: - out_root_path = in_root_path + output_root = input_root else: - # make out_root_path - if os.path.exists(out_root_path): - _report_progress('out_root_path "{}" has been detected!'.format(out_root_path), verbose) + output_root = Path(out_root_path) + if output_root.exists(): + _report_progress('out_root_path "{}" has been detected!'.format(output_root), verbose) else: _report_progress( - 'out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path), + 'out_root_path "{}" has not been detected, I will create it for you'.format(output_root), verbose, ) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] + output_root.mkdir(parents=True, exist_ok=True) + normalized_in_format = in_format.lower().lstrip('.') + in_file_path = sorted( + path for path in input_root.iterdir() + if path.is_file() and path.suffix.lower() == f'.{normalized_in_format}' + ) error_in_file_path = [] smi_list = [] for index in tqdm(range(len(in_file_path)), disable=not verbose): try: - smi_list.append(self.file_to_smi(format=in_format, file_path=in_file_path[index])) - except CONVERSION_EXCEPTIONS as exc: + smi_list.append( + self.file_to_smi( + format=normalized_in_format, + file_path=str(in_file_path[index]), + raise_on_error=not continue_on_error, + ) + ) + except ConversionError as exc: logger.warning("Failed to read SMILES from %s: %s", in_file_path[index], exc) error_in_file_path.append(in_file_path[index]) + if not continue_on_error: + raise # print('There may something wrong in {}, please check it carefully!'.format(in_file_path[index])) # When there is something wrong, print some warning @@ -367,13 +420,14 @@ def batch_file_to_smi(self, in_format, in_root_path, out_root_path=None, verbose for i in error_in_file_path: print('There may something wrong in {}, please check it carefully!'.format(i)) - with open(os.path.join(out_root_path, 'SMILES.txt'), 'w') as f: + smiles_output_path = output_root / 'SMILES.txt' + with smiles_output_path.open('w', encoding='utf-8') as f: for i in smi_list: f.write(i + '\n') return smi_list def batch_file_to_smi_mpi(self, in_format, in_root_path, out_root_path=None, - n_jobs=1, batch_size='auto', verbose=True): + n_jobs=1, batch_size='auto', verbose=True, continue_on_error=True): """ Converting a batch of files into SMILES with MPI acceleration. :param in_format: str. Format of the file. @@ -383,28 +437,39 @@ def batch_file_to_smi_mpi(self, in_format, in_root_path, out_root_path=None, :param n_jobs: int. number of CPU cores you want to use when converting file to SMILES. :param batch_size: int or str. Number of tasks per CPU core you want to use when converting file to SMILES. Default='auto'. :param verbose: if True, print progress messages. Set False for programmatic or GUI use. + :param continue_on_error: if True, return all task results even when some conversions fail. """ + input_root = Path(in_root_path) if out_root_path is None: - out_root_path = in_root_path + output_root = input_root else: - # make out_root_path - if os.path.exists(out_root_path): - _report_progress('out_root_path "{}" has been detected!'.format(out_root_path), verbose) + output_root = Path(out_root_path) + if output_root.exists(): + _report_progress('out_root_path "{}" has been detected!'.format(output_root), verbose) else: _report_progress( - 'out_root_path "{}" has not been detected, I will create it for you'.format(out_root_path), + 'out_root_path "{}" has not been detected, I will create it for you'.format(output_root), verbose, ) - os.makedirs(out_root_path) - # end - in_file_names = os.listdir(in_root_path) - in_file_names = [i for i in in_file_names if i.endswith(in_format)] - in_file_path = [os.path.join(in_root_path, i) for i in in_file_names] + output_root.mkdir(parents=True, exist_ok=True) + normalized_in_format = in_format.lower().lstrip('.') + in_file_path = sorted( + path for path in input_root.iterdir() + if path.is_file() and path.suffix.lower() == f'.{normalized_in_format}' + ) - task = [delayed(self.file_to_smi)(format=in_format, file_path=in_file_path[index]) for index in range(len(in_file_path))] + task = [ + delayed(self.file_to_smi)( + format=normalized_in_format, + file_path=str(in_file_path[index]), + raise_on_error=not continue_on_error, + ) + for index in range(len(in_file_path)) + ] smi_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) - with open(os.path.join(out_root_path, 'SMILES.txt'), 'w') as f: + smiles_output_path = output_root / 'SMILES.txt' + with smiles_output_path.open('w', encoding='utf-8') as f: for i in smi_list: f.write(i + '\n') return smi_list diff --git a/groupy/gp_counter.py b/groupy/gp_counter.py index 595bd56..8929ca8 100644 --- a/groupy/gp_counter.py +++ b/groupy/gp_counter.py @@ -1,4 +1,5 @@ import logging +from pathlib import Path from tqdm import tqdm from rdkit import Chem @@ -3238,13 +3239,14 @@ def __init__(self): ] self.f_order_group_function_order, self.s_order_group_function_order, self.t_order_group_function_order = self.loader.load_group_order() - def count_a_mol(self, mol, clear_mode=False, add_note=False, add_smiles=False): + def count_a_mol(self, mol, clear_mode=False, add_note=False, add_smiles=False, raise_on_error=False): """ Counting number of different groups of a molecule. :param mol: instance of rdkit.Chem.rdchem.Mol or SMILES str which will be converter to rdkit.Chem.rdchem.Mol automatically. :param clear_mode: bool. If set to True, The dictionary that stores the results will only retain groups with a count that is not zero. Default=False. :param add_note: bool. If set to True, a note(SMILES of current molecule) will be added to the dictionary that stores the results, i.e. {note: SMILES}. Default=False. :param add_smiles: bool. If set to True, The SMILES of current molecule will be added to the dictionary that stores the results, i.e. {smiles: SMILES}. Default=False. + :param raise_on_error: if True, re-raise expected input errors instead of returning zero counts. :return: Dict. A dictionary that stores the results. """ init_smi = mol @@ -3264,11 +3266,13 @@ def count_a_mol(self, mol, clear_mode=False, add_note=False, add_smiles=False): self.result = {k: v for k, v in self.result.items() if v} return self.result except (InvalidSmilesError, TypeError) as exc: + if raise_on_error: + raise logger.warning("Failed to count groups for %r: %s", init_smi, exc) return self.init_result.copy() def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv', - add_note=False, add_smiles=False, verbose=True): + add_note=False, add_smiles=False, verbose=True, continue_on_error=True): """ Counting number of different groups of a batch of molecules. :param smiles_file_path: str. Path of the file(.txt, .xlsx, .csv) in which saved SMILES of molecules. @@ -3276,6 +3280,7 @@ def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv' :param add_note: bool. If set to True, a note(SMILES of current molecule) will be added to the dictionary that stores the results, i.e. {note: SMILES}. Default=False. :param add_smiles: bool. If set to True, The SMILES of current molecule will be added to the dictionary that stores the results, i.e. {smiles: SMILES}. Default=False. :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. + :param continue_on_error: if True, keep legacy batch behavior and write zero rows for invalid SMILES. :return: pandas Dataframe. A dictionary that stores the results. """ _report_progress('reading the input file...', verbose) @@ -3289,16 +3294,26 @@ def count_mols(self, smiles_file_path, count_result_file_path='count_result.csv' _report_progress('Done, totally detected {} molecules, start counting...'.format(mol_number), verbose) count_result_dict_list = [] for i in tqdm(smiles_iterator, disable=not verbose): - count_result_dict_list.append(self.count_a_mol(i, add_note=add_note, add_smiles=add_smiles)) + count_result_dict_list.append( + self.count_a_mol( + i, + add_note=add_note, + add_smiles=add_smiles, + raise_on_error=not continue_on_error, + ) + ) _report_progress('Done!', verbose) _report_progress('writing to csv...', verbose) result = pd.DataFrame(count_result_dict_list) - result.to_csv(count_result_file_path, index_label='index') + output_path = Path(count_result_file_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + result.to_csv(output_path, index_label='index') _report_progress('Done!', verbose) return result def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result.csv', - add_note=False, add_smiles=False, n_jobs=1, batch_size='auto', verbose=True): + add_note=False, add_smiles=False, n_jobs=1, batch_size='auto', verbose=True, + continue_on_error=True): """ Counting number of different groups of a batch of molecules with MPI acceleration. :param smiles_file_path: str. Path of the file(.txt, .xlsx, .csv) in which saved SMILES of molecules. @@ -3308,6 +3323,7 @@ def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result. :param n_jobs: int. number of CPU cores you want to use when counting groups. :param batch_size: int or str. Number of tasks per CPU core you want to use when counting groups. Default='auto'. :param verbose: if True, print progress messages. Set False for programmatic or GUI use. + :param continue_on_error: if True, keep legacy batch behavior and write zero rows for invalid SMILES. :return: pandas Dataframe. A dictionary that stores the results. """ _report_progress('reading the input file...', verbose) @@ -3319,12 +3335,22 @@ def count_mols_mpi(self, smiles_file_path, count_result_file_path='count_result. mol_number = len(smiles_iterator) _report_progress('Done, totally detected {} molecules, start counting...'.format(mol_number), verbose) - task = [delayed(self.count_a_mol)(i, add_note=add_note, add_smiles=add_smiles) for i in smiles_iterator] + task = [ + delayed(self.count_a_mol)( + i, + add_note=add_note, + add_smiles=add_smiles, + raise_on_error=not continue_on_error, + ) + for i in smiles_iterator + ] count_result_dict_list = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) _report_progress('Done!', verbose) _report_progress('writing to csv...', verbose) result = pd.DataFrame(count_result_dict_list) - result.to_csv(count_result_file_path, index_label='index') + output_path = Path(count_result_file_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + result.to_csv(output_path, index_label='index') _report_progress('Done!', verbose) return result diff --git a/groupy/gp_generator.py b/groupy/gp_generator.py index 56624d5..82966ea 100644 --- a/groupy/gp_generator.py +++ b/groupy/gp_generator.py @@ -1,6 +1,6 @@ import logging from rdkit import Chem -import os +from pathlib import Path from tqdm import tqdm from joblib import Parallel, delayed import time @@ -116,8 +116,10 @@ def smi_to_gjf(self, smi, nproc='12', mem='12GB', chk_path=None, gjf_path=None, # write gjf # 判断是否存在同名gjf - if os.path.exists(gjf_path): - os.remove(gjf_path) + gjf_output_path = Path(gjf_path) + gjf_output_path.parent.mkdir(parents=True, exist_ok=True) + if gjf_output_path.exists(): + gjf_output_path.unlink() self.write_gjf_link0_and_keyword(gjf_path=gjf_path, chk_path=chk_path, nproc=nproc, mem=mem, gaussian_keywords=gaussian_keywords, charge_and_multiplicity=charge_and_multiplicity, note=smi) @@ -136,8 +138,8 @@ def smi_to_gjf(self, smi, nproc='12', mem='12GB', chk_path=None, gjf_path=None, logger.warning("Failed to generate gjf for %r: %s", smi, exc) return False finally: - if temp_xyz_path is not None and os.path.exists(temp_xyz_path): - os.remove(temp_xyz_path) + if temp_xyz_path is not None: + Path(temp_xyz_path).unlink(missing_ok=True) def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, nproc='12', mem='12GB', @@ -146,6 +148,7 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, index_start=0, fail_file_path=None, succeed_file_path=None, verbose=True, + continue_on_error=True, ): """ Generating some gjf files based on a file in which saved some SMILES. @@ -163,8 +166,11 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, :param fail_file_path: optional path for writing SMILES strings that failed to generate gjf files. :param succeed_file_path: optional path for writing SMILES strings that successfully generated gjf files. :param verbose: if True, print progress messages and progress bars. Set False for programmatic or GUI use. + :param continue_on_error: if True, continue generating later SMILES after one generation fails. :return: None """ + if gjf_root_path is None: + gjf_root_path = 'gjf' _report_progress('reading input file...', verbose) smiles_iterator = load_smiles_file(smiles_file_path) mol_number = len(smiles_iterator) @@ -174,16 +180,15 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, verbose, ) - # make gjf_root_path - if os.path.exists(gjf_root_path): - _report_progress('gjf_root_path "{}" has been detected!'.format(gjf_root_path), verbose) + gjf_root = Path(gjf_root_path) + if gjf_root.exists(): + _report_progress('gjf_root_path "{}" has been detected!'.format(gjf_root), verbose) else: _report_progress( - 'gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path), + 'gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root), verbose, ) - os.makedirs(gjf_root_path) - # end + gjf_root.mkdir(parents=True, exist_ok=True) succeed = [] fail = [] @@ -191,15 +196,21 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, smi = smi.strip() index += index_start chk_path = '{}.chk'.format(str(index).zfill(zfill_number)) - gjf_path = os.path.join(gjf_root_path, '{}.gjf'.format(str(index).zfill(zfill_number))) + gjf_path = gjf_root / '{}.gjf'.format(str(index).zfill(zfill_number)) generate_success_flag = self.smi_to_gjf(smi=smi, nproc=nproc, mem=mem, - chk_path=chk_path, gjf_path=gjf_path, + chk_path=chk_path, gjf_path=str(gjf_path), gaussian_keywords=gaussian_keywords, charge_and_multiplicity=charge_and_multiplicity, add_other_tasks=add_other_tasks, other_tasks=other_tasks, ) if not generate_success_flag: fail.append(smi) + if not continue_on_error: + if fail_file_path is not None: + write_text_lines(fail, fail_file_path) + if succeed_file_path is not None: + write_text_lines(succeed, succeed_file_path) + raise ConversionError(f'Failed to generate gjf for {smi!r}.') else: succeed.append(smi) @@ -208,7 +219,7 @@ def batch_smi_to_gjf(self, smiles_file_path, gjf_root_path=None, if succeed_file_path is not None: write_text_lines(succeed, succeed_file_path) if len(fail) == 0: - _report_progress('done! all .gjf files has been saved in {}'.format(gjf_root_path), verbose) + _report_progress('done! all .gjf files has been saved in {}'.format(gjf_root), verbose) else: logger.warning("Failed to generate gjf files for SMILES: %s", fail) if verbose: @@ -220,7 +231,7 @@ def batch_smi_to_gjf_mpi(self, smiles_file_path, gjf_root_path=None, nproc='12', mem='12GB', gaussian_keywords=None, charge_and_multiplicity=None, add_other_tasks=False, other_tasks: list = None, - n_jobs=1, batch_size='auto', verbose=True + n_jobs=1, batch_size='auto', verbose=True, continue_on_error=True ): """ Generating some gjf files based on a file in which saved some SMILES with MPI acceleration. @@ -238,8 +249,11 @@ def batch_smi_to_gjf_mpi(self, smiles_file_path, gjf_root_path=None, :param n_jobs: int. number of CPU cores you want to use when generating gjf file. :param batch_size: int or str. Number of tasks per CPU core you want to use when generating gjf file. Default='auto'. :param verbose: if True, print progress messages. Set False for programmatic or GUI use. + :param continue_on_error: if True, return all task results even when some generations fail. :return: None """ + if gjf_root_path is None: + gjf_root_path = 'gjf' _report_progress('reading input file...', verbose) smiles_iterator = load_smiles_file(smiles_file_path) mol_number = len(smiles_iterator) @@ -249,25 +263,27 @@ def batch_smi_to_gjf_mpi(self, smiles_file_path, gjf_root_path=None, verbose, ) - # make gjf_root_path - if os.path.exists(gjf_root_path): - _report_progress('gjf_root_path "{}" has been detected!'.format(gjf_root_path), verbose) + gjf_root = Path(gjf_root_path) + if gjf_root.exists(): + _report_progress('gjf_root_path "{}" has been detected!'.format(gjf_root), verbose) else: _report_progress( - 'gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root_path), + 'gjf_root_path "{}" has not been detected, I will create it for you'.format(gjf_root), verbose, ) - os.makedirs(gjf_root_path) + gjf_root.mkdir(parents=True, exist_ok=True) # task task = [delayed(self.smi_to_gjf)(smi=smi, nproc=nproc, mem=mem, chk_path='{}.chk'.format(str(index).zfill(zfill_number)), - gjf_path=os.path.join(gjf_root_path, '{}.gjf'.format(str(index).zfill(zfill_number))), + gjf_path=str(gjf_root / '{}.gjf'.format(str(index).zfill(zfill_number))), gaussian_keywords=gaussian_keywords, charge_and_multiplicity=charge_and_multiplicity, add_other_tasks=add_other_tasks, other_tasks=other_tasks,) for index, smi in enumerate(smiles_iterator)] result = Parallel(n_jobs=n_jobs, batch_size=batch_size)(task) + if not continue_on_error and not all(result): + raise ConversionError('Failed to generate one or more gjf files.') return result def batch_smi_to_gjf_parallel(self, *args, **kwargs): @@ -285,7 +301,7 @@ def write_gjf_link0_and_keyword(gjf_path, chk_path, nproc, mem, gaussian_keyword """ Only used in Generator.smi_to_gjf() """ - with open(gjf_path, 'a') as gjf: + with Path(gjf_path).open('a', encoding='utf-8') as gjf: if add_link1: gjf.write('--link1--' + '\n') gjf.write(f'%nproc={nproc}' + '\n') @@ -306,9 +322,10 @@ def write_gjf_coord(gjf_path, xyz_path): """ Only used in Generator.smi_to_gjf() """ - xyz = open(xyz_path) - with open(gjf_path, 'a') as gjf: - for i in xyz.readlines()[2:]: + with Path(xyz_path).open(encoding='utf-8') as xyz: + coord_lines = xyz.readlines()[2:] + with Path(gjf_path).open('a', encoding='utf-8') as gjf: + for i in coord_lines: gjf.write(i) gjf.write('\n\n') return None @@ -318,7 +335,7 @@ def write_gjf_blank_line(gjf_path, blank_line_number=1): """ Only used in Generator.smi_to_gjf() """ - with open(gjf_path, 'a') as gjf: + with Path(gjf_path).open('a', encoding='utf-8') as gjf: gjf.write('\n' * blank_line_number) return None diff --git a/groupy/gp_viewer.py b/groupy/gp_viewer.py index 2ab00d2..ff134f0 100644 --- a/groupy/gp_viewer.py +++ b/groupy/gp_viewer.py @@ -1,8 +1,12 @@ -import os +import logging +from pathlib import Path from pprint import pprint +import tempfile from groupy.gp_convertor import Convertor +logger = logging.getLogger(__name__) +VIEWER_READ_EXCEPTIONS = (OSError, RuntimeError, ValueError) _ASE_INSTALL_HINT = ( "ASE is required for molecular visualization. " @@ -53,19 +57,34 @@ def view_mol(self, mol, mol_type='smi'): read = _load_ase_read() view = _load_ase_view() if isinstance(mol, str): + temp_xyz_path = None if mol_type in ['smi', 'smiles', 'SMILES']: convertor = Convertor() - convertor.smi_to_xyz(smi=mol, xyz_path='temp.xyz') - mol = read(filename='temp.xyz', format='xyz') - os.remove('temp.xyz') + with tempfile.NamedTemporaryFile(suffix='.xyz', delete=False) as temp_xyz: + temp_xyz_path = Path(temp_xyz.name) + try: + convertor.smi_to_xyz(smi=mol, xyz_path=str(temp_xyz_path)) + mol = read(filename=str(temp_xyz_path), format='xyz') + finally: + temp_xyz_path.unlink(missing_ok=True) else: try: mol = read(filename=mol, format=mol_type) - except: + except VIEWER_READ_EXCEPTIONS as exc: + logger.info("ASE could not read %s as %s directly: %s", mol, mol_type, exc) convertor = Convertor() - convertor.convert_file_type(in_format=mol_type, in_path=mol, out_format='xyz', out_path='temp.xyz') - mol = read(filename='temp.xyz', format='xyz') - os.remove('temp.xyz') + with tempfile.NamedTemporaryFile(suffix='.xyz', delete=False) as temp_xyz: + temp_xyz_path = Path(temp_xyz.name) + try: + convertor.convert_file_type( + in_format=mol_type, + in_path=mol, + out_format='xyz', + out_path=str(temp_xyz_path), + ) + mol = read(filename=str(temp_xyz_path), format='xyz') + finally: + temp_xyz_path.unlink(missing_ok=True) view(mol) return None diff --git a/groupy/groupy_main.py b/groupy/groupy_main.py index ca993db..c389434 100644 --- a/groupy/groupy_main.py +++ b/groupy/groupy_main.py @@ -124,7 +124,7 @@ def sub_function_minus_2_of_main_function_5(): batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') try: batch_size = int(batch_size) - except: + except ValueError: pass convertor = Convertor() convertor.batch_smi_to_xyz_mpi(smiles_file_path=smiles_file_path, xyz_root_path=xyz_root_path, @@ -189,7 +189,7 @@ def sub_function_minus_4_of_main_function_5(): batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') try: batch_size = int(batch_size) - except: + except ValueError: pass convertor = Convertor() convertor.batch_convert_file_type_mpi(in_format=in_format, in_root_path=in_root_path, @@ -249,7 +249,7 @@ def sub_function_minus_6_of_main_function_5(): batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') try: batch_size = int(batch_size) - except: + except ValueError: pass convertor = Convertor() smi_list = convertor.batch_file_to_smi_mpi(in_format=in_format, in_root_path=in_root_path, @@ -429,7 +429,7 @@ def sub_function_minus_8_of_main_function_5(): batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') try: batch_size = int(batch_size) - except: + except ValueError: pass generator = Generator() @@ -531,7 +531,7 @@ def main_function_minus_3(): batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') try: batch_size = int(batch_size) - except: + except ValueError: pass calculator = Calculator() calculator.calculate_mols_mpi(smiles_file_path=smiles_file_path, @@ -569,7 +569,7 @@ def main_function_minus_4(): batch_size = input('input batch size for task decomposition. e.g. 20, you can also enter "auto" \n') try: batch_size = int(batch_size) - except: + except ValueError: pass counter = Counter() counter.count_mols_mpi(smiles_file_path=smiles_file_path, diff --git a/tests/test_smoke.py b/tests/test_smoke.py index eeaa1ba..1e05313 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -9,6 +9,7 @@ import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path +from types import SimpleNamespace import pandas as pd @@ -167,6 +168,15 @@ def test_release_checklist_documents_double_click_distribution(self): self.assertIn("CSV export", checklist) self.assertIn("OpenBabel from conda-forge", checklist) + def test_ci_workflow_runs_build_and_package_checks(self): + workflow_path = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "ci.yml" + workflow = workflow_path.read_text(encoding="utf-8") + + self.assertIn("python -W error::ResourceWarning -m unittest discover -s tests", workflow) + self.assertIn("python -m compileall groupy tests scripts", workflow) + self.assertIn("python -m build", workflow) + self.assertIn("python scripts/build_windows_app.py --dry-run", workflow) + class CoreChemistrySmokeTests(unittest.TestCase): def test_counter_counts_cyclopentane(self): @@ -309,6 +319,44 @@ def test_calculator_batch_writes_error_file_only_when_requested(self): self.assertEqual(error_path.read_text(encoding="utf-8"), "not-a-smiles\n") self.assertTrue(output_path.exists()) + def test_calculator_batch_can_stop_on_invalid_smiles(self): + from groupy.exceptions import InvalidSmilesError + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + output_path = tmp_path / "calculate.csv" + input_path.write_text("not-a-smiles\n", encoding="utf-8") + + with self.assertRaises(InvalidSmilesError): + Calculator().calculate_mols( + str(input_path), + properties_file_path=str(output_path), + continue_on_error=False, + verbose=False, + ) + + self.assertFalse(output_path.exists()) + + def test_counter_batch_can_stop_on_invalid_smiles(self): + from groupy.exceptions import InvalidSmilesError + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + output_path = tmp_path / "count.csv" + input_path.write_text("not-a-smiles\n", encoding="utf-8") + + with self.assertRaises(InvalidSmilesError): + Counter().count_mols( + str(input_path), + count_result_file_path=str(output_path), + continue_on_error=False, + verbose=False, + ) + + self.assertFalse(output_path.exists()) + def test_parallel_aliases_for_counter_and_calculator(self): with tempfile.TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) @@ -398,6 +446,29 @@ def block_openbabel(name, globals=None, locals=None, fromlist=(), level=0): finally: builtins.__import__ = original_import + def test_convert_file_api_uses_predictable_default_output(self): + from groupy.api import convert_file + from groupy.gp_convertor import Convertor + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "molecule.xyz" + input_path.write_text("fake xyz\n", encoding="utf-8") + original_convert_file_type = Convertor.convert_file_type + + def fake_convert_file_type(in_format, in_path, out_format, out_path=None): + Path(out_path).write_text(f"converted from {in_format} to {out_format}\n", encoding="utf-8") + return str(out_path) + + Convertor.convert_file_type = staticmethod(fake_convert_file_type) + try: + output_path = convert_file(input_path, "xyz", "mol2") + finally: + Convertor.convert_file_type = original_convert_file_type + + self.assertEqual(output_path, tmp_path / "molecule.mol2") + self.assertTrue(output_path.exists()) + def test_convertor_reports_invalid_smiles_for_xyz(self): from groupy.gp_convertor import Convertor @@ -515,6 +586,27 @@ def fake_smi_to_xyz(smi, xyz_path=None): self.assertEqual(stderr.getvalue(), "") self.assertTrue((xyz_root / "0000.xyz").exists()) + def test_batch_smi_to_xyz_can_stop_on_failure(self): + from groupy.exceptions import ConversionError + from groupy.gp_convertor import Convertor + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "smiles.txt" + xyz_root = tmp_path / "xyz" + input_path.write_text("invalid\n", encoding="utf-8") + + convertor = Convertor() + convertor.smi_to_xyz = lambda smi, xyz_path=None: False + + with self.assertRaises(ConversionError): + convertor.batch_smi_to_xyz( + str(input_path), + str(xyz_root), + verbose=False, + continue_on_error=False, + ) + def test_batch_convert_file_type_can_run_quietly_for_gui_use(self): from groupy.gp_convertor import Convertor @@ -558,7 +650,7 @@ def test_batch_file_to_smi_can_run_quietly_for_gui_use(self): (input_root / "molecule.mol").write_text("fake mol\n", encoding="utf-8") convertor = Convertor() - convertor.file_to_smi = lambda file_path, format=None: "C1CCCC1" + convertor.file_to_smi = lambda file_path, format=None, **kwargs: "C1CCCC1" stdout = io.StringIO() stderr = io.StringIO() @@ -759,6 +851,12 @@ def fake_batch_smi_to_gjf_mpi(*args, **kwargs): class ViewerSmokeTests(unittest.TestCase): + def test_viewer_source_has_no_bare_except(self): + source_path = Path(__file__).resolve().parents[1] / "groupy" / "gp_viewer.py" + source = source_path.read_text(encoding="utf-8") + + self.assertNotIn("except:", source) + def test_viewer_module_does_not_import_ase_at_import_time(self): command = [ sys.executable, @@ -996,6 +1094,54 @@ def test_calculate_cli_writes_csv_from_input_file(self): self.assertEqual(result["smiles"].tolist(), ["C1CCCC1", "CCO"]) self.assertIn("molar_mass", result.columns) + def test_convert_cli_uses_api_and_outputs_json(self): + from groupy import api + from groupy import cli + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + input_path = tmp_path / "molecule.xyz" + output_path = tmp_path / "molecule.mol2" + input_path.write_text("fake xyz\n", encoding="utf-8") + calls = [] + original_convert_file = api.convert_file + + def fake_convert_file(input_path, from_format, to_format, output_path=None): + calls.append(SimpleNamespace( + input_path=Path(input_path), + from_format=from_format, + to_format=to_format, + output_path=Path(output_path), + )) + return Path(output_path) + + api.convert_file = fake_convert_file + stdout = io.StringIO() + try: + with redirect_stdout(stdout): + exit_code = cli.main( + [ + "convert", + "--input", + str(input_path), + "--from", + "xyz", + "--to", + "mol2", + "--output", + str(output_path), + ] + ) + finally: + api.convert_file = original_convert_file + + self.assertEqual(exit_code, 0) + self.assertEqual(calls[0].input_path, input_path) + self.assertEqual(calls[0].from_format, "xyz") + self.assertEqual(calls[0].to_format, "mol2") + self.assertEqual(calls[0].output_path, output_path) + self.assertEqual(json.loads(stdout.getvalue()), {"output": str(output_path)}) + @unittest.skipUnless(importlib.util.find_spec("openbabel"), "OpenBabel is required by the legacy interactive CLI") def test_legacy_cli_can_start_and_exit(self): command = [ From 29df694b5ba747c478b599f0eac5f8ddb61aacb8 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 11:05:24 +0800 Subject: [PATCH 25/29] Record packaged app validation --- REFACTORING_PLAN.md | 12 ++++++------ RELEASE_CHECKLIST.md | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 00a6252..589399f 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -232,7 +232,7 @@ Current parallel API status: - [x] Add GUI smoke tests where practical. - [x] Add packaging script for Windows executable builds. - [x] Test the packaged app can launch and run. -- [ ] Test the packaged app on a clean Windows environment. +- [x] Test the packaged app on a clean Windows environment. - [x] Document limitations around OpenBabel and optional conversion features. - [x] Add a release checklist for ordinary-user distribution. @@ -247,6 +247,7 @@ Current GUI status: - `scripts/build_windows_app.py` builds a PyInstaller app folder by default at `dist/Groupy/Groupy.exe`. - The build script supports `--dry-run` for command inspection without requiring PyInstaller/PySide6. - The user confirmed `dist/Groupy/Groupy.exe` can run normally. +- The user confirmed packaged-app validation can run normally after the release checklist pass. - The generated `_internal` folder is currently large, about 753.5 MB, mainly because Intel MKL DLLs are bundled from the build environment. - The build script now excludes clearly unused optional modules by default and supports `--no-default-excludes` / `--exclude-module` for tuning. - README now recommends building from a clean conda-forge packaging environment before trying manual file exclusions. @@ -256,14 +257,13 @@ Current GUI status: ## Current Known Review Findings -- Clean Windows validation still needs to be run outside the development machine. - `_internal` size optimization is intentionally deferred until the release workflow is stable. - The legacy interactive menu is still present for compatibility, but new automation should use `groupy.api`, `Groupy count`, `Groupy calculate`, or `Groupy convert`. ## Near-Term Next Step -Continue with Phase 9: +Continue with Phase 9 packaging optimization: -1. Run `RELEASE_CHECKLIST.md` on a clean Windows environment. -2. Record any clean Windows findings in the release notes or README. -3. Return to `_internal` size optimization after the release workflow is stable. +1. Measure the current packaged app contents again. +2. Identify the largest removable or avoidable dependencies in `_internal`. +3. Optimize package size in small steps, retesting `dist/Groupy/Groupy.exe` after each change. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 1dffbce..426ac8b 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -84,3 +84,7 @@ Test the final artifact on a Windows machine without the development environment - Include release notes that mention optional dependency boundaries. - Record the build date, source commit, Python version, and build environment. - Keep `_internal` size optimization as a separate post-release task until the main workflow is stable. + +## 8. Validation Record + +- 2026-06-25: User confirmed the packaged app validation can run normally. From b463976af113b91664fdc86c10dfe39b21bfce41 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 11:12:34 +0800 Subject: [PATCH 26/29] Enhance GUI input options and structure preview --- README.md | 2 + REFACTORING_PLAN.md | 6 + RELEASE_CHECKLIST.md | 3 + groupy/gui/app.py | 258 +++++++++++++++++++++++++++++++++++++++---- tests/test_smoke.py | 32 ++++++ 5 files changed, 282 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e53635c..3837611 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ To launch the desktop GUI after installing GUI support: `Groupy-GUI` +The GUI supports SMILES text input, SMILES file import, 2D structure preview, property calculation, group counting, calculation/counting options, and CSV export. + To build a double-clickable Windows app folder: `python scripts/build_windows_app.py` diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 589399f..c18b1a8 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -23,6 +23,7 @@ Recommended GUI scope: - Single-molecule group counting from SMILES. - Batch calculation from `.txt`, `.csv`, or `.xlsx`. - Batch group counting from `.txt`, `.csv`, or `.xlsx`. +- 2D structure preview for the selected or first SMILES. - CSV export for tabular results. - Clear dependency warnings for optional conversion/viewer features. @@ -224,8 +225,11 @@ Current parallel API status: - [x] Add a `Groupy-GUI` entry point. - [x] Build first GUI screen for: - [x] SMILES input + - [x] SMILES file import + - [x] 2D structure preview - [x] property calculation - [x] group counting + - [x] calculation and group-counting options - [x] CSV export - [x] Visually smoke-test the initial GUI runtime. - [x] Add background worker handling so long calculations do not freeze the UI. @@ -242,6 +246,7 @@ Current GUI status: - `python -m groupy.gui --check` and `Groupy-GUI --check` report a clear install hint when PySide6 is missing. - `groupy.gui` imports without importing PySide6, so core CLI/API usage remains lightweight. - The user confirmed the first GUI can run normally in a GUI-capable environment. +- The GUI now supports SMILES file import, RDKit 2D structure preview, parameter type selection, hydrocarbon filtering, zero-count group display, and optional SMILES columns. - Property calculation and group counting now run in a `QThread` worker instead of the GUI thread. - Worker-based GUI behavior still needs visual confirmation in a PySide6 environment. - `scripts/build_windows_app.py` builds a PyInstaller app folder by default at `dist/Groupy/Groupy.exe`. @@ -259,6 +264,7 @@ Current GUI status: - `_internal` size optimization is intentionally deferred until the release workflow is stable. - The legacy interactive menu is still present for compatibility, but new automation should use `groupy.api`, `Groupy count`, `Groupy calculate`, or `Groupy convert`. +- Advanced GUI pages for OpenBabel conversion, Gaussian input generation, and optional visualization are planned for a later phase. ## Near-Term Next Step diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 426ac8b..88eda62 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -54,8 +54,11 @@ dist/Groupy/Groupy.exe Run `dist/Groupy/Groupy.exe` and check: - The window opens by double-clicking. +- SMILES file import works for a small `.txt`, `.csv`, or `.xlsx` input. +- The 2D structure preview updates for `C1CCCC1`. - Property calculation works for `C1CCCC1`. - Group counting works for `C1CCCC1`. +- Parameter and group-counting options can be changed before running. - CSV export creates a readable file. - Invalid SMILES input produces an error row instead of crashing. - Long-running calculation or counting does not freeze the UI. diff --git a/groupy/gui/app.py b/groupy/gui/app.py index fe7a54a..13ff646 100644 --- a/groupy/gui/app.py +++ b/groupy/gui/app.py @@ -3,13 +3,15 @@ from __future__ import annotations import argparse +from io import BytesIO import importlib.util import sys from pathlib import Path from typing import Any +from groupy.chem import ensure_mol from groupy.api import calculate_smiles, count_smiles -from groupy.io import write_records_csv +from groupy.io import load_smiles_file, write_records_csv INSTALL_HINT = ( "PySide6 is required for the Groupy desktop GUI. " @@ -22,14 +24,54 @@ def is_pyside6_available() -> bool: return importlib.util.find_spec("PySide6") is not None -def calculate_records(smiles_values: list[str]) -> list[dict[str, Any]]: +def calculate_records( + smiles_values: list[str], + *, + parameter_type: str = "step_wise", + check_hydrocarbon: bool = True, +) -> list[dict[str, Any]]: """Calculate properties for a list of SMILES strings.""" - return [calculate_smiles(smiles) for smiles in smiles_values] + return [ + calculate_smiles( + smiles, + parameter_type=parameter_type, + check_hydrocarbon=check_hydrocarbon, + ) + for smiles in smiles_values + ] + + +def count_records( + smiles_values: list[str], + *, + include_zero: bool = False, + include_smiles: bool = True, +) -> list[dict[str, Any]]: + """Count groups for a list of SMILES strings.""" + return [ + count_smiles( + smiles, + include_zero=include_zero, + include_smiles=include_smiles, + ) + for smiles in smiles_values + ] -def count_records(smiles_values: list[str]) -> list[dict[str, Any]]: - """Count groups for a list of SMILES strings.""" - return [count_smiles(smiles) for smiles in smiles_values] +def load_smiles_text(path: str | Path) -> str: + """Load a SMILES file and return text suitable for the GUI editor.""" + return "\n".join(load_smiles_file(path)) + + +def render_smiles_png(smiles: str, *, width: int = 360, height: int = 280) -> bytes: + """Render one SMILES string to PNG bytes using RDKit.""" + from rdkit.Chem import Draw + + molecule = ensure_mol(smiles) + image = Draw.MolToImage(molecule, size=(width, height)) + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() def main(argv: list[str] | None = None) -> int: @@ -50,15 +92,15 @@ def main(argv: list[str] | None = None) -> int: print("PySide6 is available.") return 0 - from PySide6 import QtCore, QtWidgets + from PySide6 import QtCore, QtGui, QtWidgets app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1]) - window = _create_main_window(QtCore, QtWidgets) + window = _create_main_window(QtCore, QtGui, QtWidgets) window.show() return app.exec() -def _create_main_window(QtCore: Any, QtWidgets: Any): +def _create_main_window(QtCore: Any, QtGui: Any, QtWidgets: Any): class BatchWorker(QtCore.QObject): finished = QtCore.Signal(object, str) failed = QtCore.Signal(str) @@ -82,10 +124,13 @@ class GroupyMainWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self._records: list[dict[str, Any]] = [] + self._record_smiles_values: list[str] = [] + self._pending_smiles_values: list[str] = [] + self._structure_pixmap = None self._worker = None self._worker_thread = None self.setWindowTitle("Groupy") - self.resize(980, 680) + self.resize(1180, 720) central = QtWidgets.QWidget() self.setCentralWidget(central) @@ -93,17 +138,37 @@ def __init__(self): root_layout.setContentsMargins(14, 14, 14, 14) root_layout.setSpacing(10) + content_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) + root_layout.addWidget(content_splitter, stretch=1) + + work_panel = QtWidgets.QWidget() + work_layout = QtWidgets.QVBoxLayout(work_panel) + work_layout.setContentsMargins(0, 0, 10, 0) + work_layout.setSpacing(10) + content_splitter.addWidget(work_panel) + + preview_panel = QtWidgets.QWidget() + preview_layout = QtWidgets.QVBoxLayout(preview_panel) + preview_layout.setContentsMargins(10, 0, 0, 0) + preview_layout.setSpacing(8) + content_splitter.addWidget(preview_panel) + input_label = QtWidgets.QLabel("SMILES") - root_layout.addWidget(input_label) + work_layout.addWidget(input_label) self.smiles_input = QtWidgets.QPlainTextEdit() self.smiles_input.setPlaceholderText("Enter one SMILES per line") self.smiles_input.setPlainText("C1CCCC1") self.smiles_input.setMinimumHeight(96) - root_layout.addWidget(self.smiles_input) + self.smiles_input.textChanged.connect(self._preview_first_input_smiles) + work_layout.addWidget(self.smiles_input) action_layout = QtWidgets.QHBoxLayout() - root_layout.addLayout(action_layout) + work_layout.addLayout(action_layout) + + self.import_button = QtWidgets.QPushButton("Import File") + self.import_button.clicked.connect(self._import_smiles_file) + action_layout.addWidget(self.import_button) self.calculate_button = QtWidgets.QPushButton("Calculate Properties") self.calculate_button.clicked.connect(self._calculate_properties) @@ -120,15 +185,68 @@ def __init__(self): action_layout.addStretch(1) + options_layout = QtWidgets.QHBoxLayout() + work_layout.addLayout(options_layout) + + options_layout.addWidget(QtWidgets.QLabel("Parameters")) + self.parameter_type_combo = QtWidgets.QComboBox() + self.parameter_type_combo.addItem("Stepwise", "step_wise") + self.parameter_type_combo.addItem("Simultaneous", "simultaneous") + options_layout.addWidget(self.parameter_type_combo) + + self.check_hydrocarbon_box = QtWidgets.QCheckBox("Hydrocarbon check") + self.check_hydrocarbon_box.setChecked(True) + options_layout.addWidget(self.check_hydrocarbon_box) + + self.include_zero_box = QtWidgets.QCheckBox("Show zero groups") + options_layout.addWidget(self.include_zero_box) + + self.include_smiles_box = QtWidgets.QCheckBox("Include SMILES") + self.include_smiles_box.setChecked(True) + options_layout.addWidget(self.include_smiles_box) + + options_layout.addStretch(1) + self.result_table = QtWidgets.QTableWidget() self.result_table.setAlternatingRowColors(True) self.result_table.setSortingEnabled(True) self.result_table.horizontalHeader().setStretchLastSection(True) self.result_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - root_layout.addWidget(self.result_table, stretch=1) + self.result_table.itemSelectionChanged.connect(self._preview_selected_record) + work_layout.addWidget(self.result_table, stretch=1) + + preview_label = QtWidgets.QLabel("Structure") + preview_layout.addWidget(preview_label) + + self.structure_view = QtWidgets.QLabel() + self.structure_view.setAlignment(QtCore.Qt.AlignCenter) + self.structure_view.setMinimumSize(320, 260) + self.structure_view.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.structure_view.setText("No structure") + preview_layout.addWidget(self.structure_view, stretch=1) + + preview_layout.addStretch(1) + content_splitter.setStretchFactor(0, 4) + content_splitter.setStretchFactor(1, 2) + + self._busy_controls = [ + self.import_button, + self.calculate_button, + self.count_button, + self.smiles_input, + self.parameter_type_combo, + self.check_hydrocarbon_box, + self.include_zero_box, + self.include_smiles_box, + ] + self._preview_first_input_smiles() self.statusBar().showMessage("Ready") + def resizeEvent(self, event): + super().resizeEvent(event) + self._refresh_structure_pixmap() + def _smiles_values(self) -> list[str]: values = [line.strip() for line in self.smiles_input.toPlainText().splitlines() if line.strip()] if not values: @@ -136,10 +254,30 @@ def _smiles_values(self) -> list[str]: return values def _calculate_properties(self) -> None: - self._start_worker(calculate_records, "Calculated properties", "Calculating properties...") + parameter_type = self.parameter_type_combo.currentData() + check_hydrocarbon = self.check_hydrocarbon_box.isChecked() + self._start_worker( + lambda smiles_values: calculate_records( + smiles_values, + parameter_type=parameter_type, + check_hydrocarbon=check_hydrocarbon, + ), + "Calculated properties", + "Calculating properties...", + ) def _count_groups(self) -> None: - self._start_worker(count_records, "Counted groups", "Counting groups...") + include_zero = self.include_zero_box.isChecked() + include_smiles = self.include_smiles_box.isChecked() + self._start_worker( + lambda smiles_values: count_records( + smiles_values, + include_zero=include_zero, + include_smiles=include_smiles, + ), + "Counted groups", + "Counting groups...", + ) def _start_worker(self, operation, done_message: str, progress_message: str) -> None: if self._worker_thread is not None: @@ -150,6 +288,7 @@ def _start_worker(self, operation, done_message: str, progress_message: str) -> self._show_error(str(exc)) return + self._pending_smiles_values = smiles_values self._set_busy(True, progress_message) thread = QtCore.QThread(self) worker = BatchWorker(smiles_values, operation, done_message) @@ -182,9 +321,8 @@ def _clear_worker(self) -> None: self._worker_thread = None def _set_busy(self, busy: bool, message: str | None = None) -> None: - self.calculate_button.setEnabled(not busy) - self.count_button.setEnabled(not busy) - self.smiles_input.setEnabled(not busy) + for control in self._busy_controls: + control.setEnabled(not busy) self.export_button.setEnabled((not busy) and bool(self._records)) if busy: QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) @@ -194,6 +332,7 @@ def _set_busy(self, busy: bool, message: str | None = None) -> None: def _show_records(self, records: list[dict[str, Any]], message: str) -> None: self._records = records + self._record_smiles_values = list(self._pending_smiles_values) columns = _record_columns(records) self.result_table.setSortingEnabled(False) self.result_table.clear() @@ -211,8 +350,31 @@ def _show_records(self, records: list[dict[str, Any]], message: str) -> None: self.result_table.resizeColumnsToContents() self.result_table.setSortingEnabled(True) self.export_button.setEnabled(bool(records)) + if records: + self.result_table.selectRow(0) + self._preview_record(0) + else: + self._preview_first_input_smiles() self.statusBar().showMessage(f"{message}: {len(records)} molecule(s)") + def _import_smiles_file(self) -> None: + path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Import SMILES", + str(Path.cwd()), + "SMILES files (*.txt *.csv *.xlsx);;All files (*.*)", + ) + if not path: + return + try: + text = load_smiles_text(path) + except Exception as exc: + self._show_error(str(exc)) + return + self.smiles_input.setPlainText(text) + count = len([line for line in text.splitlines() if line.strip()]) + self.statusBar().showMessage(f"Imported {count} molecule(s) from {path}") + def _export_csv(self) -> None: if not self._records: self._show_error("No results to export.") @@ -232,6 +394,56 @@ def _show_error(self, message: str) -> None: QtWidgets.QMessageBox.warning(self, "Groupy", message) self.statusBar().showMessage(message) + def _preview_first_input_smiles(self) -> None: + values = [line.strip() for line in self.smiles_input.toPlainText().splitlines() if line.strip()] + if not values: + self._clear_structure("No structure") + return + self._show_structure(values[0]) + + def _preview_selected_record(self) -> None: + selected_rows = self.result_table.selectionModel().selectedRows() + if not selected_rows: + return + self._preview_record(selected_rows[0].row()) + + def _preview_record(self, row: int) -> None: + if row < 0 or row >= len(self._records): + return + smiles = _record_smiles(self._records[row]) + if smiles is None and row < len(self._record_smiles_values): + smiles = self._record_smiles_values[row] + if not smiles: + self._clear_structure("No SMILES") + return + self._show_structure(smiles) + + def _show_structure(self, smiles: str) -> None: + try: + image_bytes = render_smiles_png(smiles) + except Exception: + self._clear_structure("Invalid SMILES") + return + pixmap = QtGui.QPixmap() + pixmap.loadFromData(image_bytes, "PNG") + self._structure_pixmap = pixmap + self._refresh_structure_pixmap() + + def _refresh_structure_pixmap(self) -> None: + if self._structure_pixmap is None: + return + scaled = self._structure_pixmap.scaled( + self.structure_view.size(), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation, + ) + self.structure_view.setPixmap(scaled) + + def _clear_structure(self, message: str) -> None: + self._structure_pixmap = None + self.structure_view.clear() + self.structure_view.setText(message) + return GroupyMainWindow() @@ -245,3 +457,11 @@ def _record_columns(records: list[dict[str, Any]]) -> list[str]: if key not in columns: columns.append(key) return columns + + +def _record_smiles(record: dict[str, Any]) -> str | None: + for key in ("smiles", "note"): + value = record.get(key) + if isinstance(value, str) and value: + return value.split()[0] + return None diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1e05313..e606311 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -946,6 +946,38 @@ def test_gui_record_helpers_use_public_api(self): self.assertAlmostEqual(calculation[0]["molar_mass"], 70.135) self.assertEqual(counts[0], {"f_168": 5, "smiles": "C1CCCC1"}) + def test_gui_record_helpers_accept_user_options(self): + from groupy.gui.app import calculate_records, count_records + + calculation = calculate_records(["CCO"], check_hydrocarbon=True) + calculation_without_filter = calculate_records(["CCO"], check_hydrocarbon=False) + counts = count_records(["C1CCCC1"], include_zero=True, include_smiles=False) + + self.assertIsNone(calculation[0]["delta_Hc/(KJ/mol)"]) + self.assertIsNotNone(calculation_without_filter[0]["delta_Hc/(KJ/mol)"]) + self.assertIn("f_001", counts[0]) + self.assertNotIn("smiles", counts[0]) + + def test_gui_can_load_smiles_text_from_file(self): + from groupy.gui.app import load_smiles_text + + with tempfile.TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "smiles.txt" + input_path.write_text("C1CCCC1\nCCO\n", encoding="utf-8") + + self.assertEqual(load_smiles_text(input_path), "C1CCCC1\nCCO") + + def test_gui_can_render_smiles_structure_png(self): + from groupy.exceptions import InvalidSmilesError + from groupy.gui.app import render_smiles_png + + image = render_smiles_png("C1CCCC1", width=220, height=160) + + self.assertTrue(image.startswith(b"\x89PNG\r\n\x1a\n")) + self.assertGreater(len(image), 1000) + with self.assertRaises(InvalidSmilesError): + render_smiles_png("not-a-smiles") + class CliSmokeTests(unittest.TestCase): def test_cli_import_does_not_load_openbabel_conversion_stack(self): From ba79a9cad6678d0c82fc65805d08233aec38c26c Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 11:56:47 +0800 Subject: [PATCH 27/29] Record enhanced GUI validation --- REFACTORING_PLAN.md | 3 ++- RELEASE_CHECKLIST.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index c18b1a8..190387e 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -247,8 +247,9 @@ Current GUI status: - `groupy.gui` imports without importing PySide6, so core CLI/API usage remains lightweight. - The user confirmed the first GUI can run normally in a GUI-capable environment. - The GUI now supports SMILES file import, RDKit 2D structure preview, parameter type selection, hydrocarbon filtering, zero-count group display, and optional SMILES columns. +- The user confirmed the enhanced GUI layout and interaction are satisfactory. - Property calculation and group counting now run in a `QThread` worker instead of the GUI thread. -- Worker-based GUI behavior still needs visual confirmation in a PySide6 environment. +- Worker-based GUI behavior has been visually checked in a PySide6 environment. - `scripts/build_windows_app.py` builds a PyInstaller app folder by default at `dist/Groupy/Groupy.exe`. - The build script supports `--dry-run` for command inspection without requiring PyInstaller/PySide6. - The user confirmed `dist/Groupy/Groupy.exe` can run normally. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 88eda62..a582ed8 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -91,3 +91,4 @@ Test the final artifact on a Windows machine without the development environment ## 8. Validation Record - 2026-06-25: User confirmed the packaged app validation can run normally. +- 2026-06-25: User confirmed the enhanced GUI with import, options, and 2D structure preview looks satisfactory. From 8d971e60342f59a2965dcb7c718c244e291be3da Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 12:17:36 +0800 Subject: [PATCH 28/29] Record OpenBLAS package size reduction --- PACKAGING_SIZE_REPORT.md | 125 +++++++++++++++++++++++++++++++++++ README.md | 4 +- REFACTORING_PLAN.md | 15 +++-- RELEASE_CHECKLIST.md | 5 +- scripts/build_windows_app.py | 29 ++++++++ tests/test_smoke.py | 23 +++++++ 6 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 PACKAGING_SIZE_REPORT.md diff --git a/PACKAGING_SIZE_REPORT.md b/PACKAGING_SIZE_REPORT.md new file mode 100644 index 0000000..c9d4a56 --- /dev/null +++ b/PACKAGING_SIZE_REPORT.md @@ -0,0 +1,125 @@ +# Groupy Packaging Size Report + +Created: 2026-06-25 + +This report records the packaged Windows app size baseline and the result after rebuilding from a clean OpenBLAS-based packaging environment. + +## Current Baseline + +Measured artifact: + +```text +dist/Groupy/Groupy.exe +dist/Groupy/_internal +``` + +Overall size: + +| Path | Size | +| --- | ---: | +| `dist/Groupy` | 772.41 MB | +| `dist/Groupy/_internal` | 753.50 MB | +| `dist/Groupy/Groupy.exe` | 18.91 MB | + +Top component groups: + +| Component | Count | Size | +| --- | ---: | ---: | +| MKL DLLs | 25 | 538.61 MB | +| ICU DLLs | 12 | 46.96 MB | +| RDKit package and DLLs | 244 | 35.63 MB | +| Qt DLLs | 5 | 22.53 MB | +| PySide6 package | 121 | 22.34 MB | +| pandas package | 53 | 12.50 MB | +| matplotlib package | 211 | 11.22 MB | +| numpy package | 13 | 5.87 MB | +| Tcl/Tk files | 7 | 3.44 MB | + +Largest files: + +| File | Size | +| --- | ---: | +| `mkl_avx512.3.dll` | 71.92 MB | +| `mkl_core.3.dll` | 68.57 MB | +| `mkl_avx10.3.dll` | 64.55 MB | +| `mkl_avx2.3.dll` | 45.28 MB | +| `mkl_mc3.3.dll` | 44.96 MB | +| `mkl_intel_thread.3.dll` | 37.27 MB | +| `mkl_def.3.dll` | 35.34 MB | +| `icudt78.dll` | 31.58 MB | +| `mkl_tbb_thread.3.dll` | 28.46 MB | +| `mkl_rt.3.dll` | 26.60 MB | + +## OpenBLAS Rebuild Result + +Measured after rebuilding from a clean conda-forge packaging environment with OpenBLAS-linked numerical dependencies. + +Overall size: + +| Path | Size | +| --- | ---: | +| `dist/Groupy` | 228.43 MB | +| `dist/Groupy/_internal` | 213.04 MB | +| `dist/Groupy/Groupy.exe` | 15.39 MB | + +Top component groups: + +| Component | Count | Size | +| --- | ---: | ---: | +| ICU DLLs | 3 | 38.21 MB | +| RDKit package and DLLs | 244 | 35.63 MB | +| OpenBLAS/BLAS DLLs | 3 | 27.32 MB | +| Qt DLLs | 5 | 22.53 MB | +| PySide6 package | 120 | 22.24 MB | +| pandas package | 53 | 12.50 MB | +| numpy package | 13 | 5.87 MB | +| matplotlib package | 0 | 0.00 MB | +| MKL DLLs | 0 | 0.00 MB | + +Largest files: + +| File | Size | +| --- | ---: | +| `icudt78.dll` | 31.58 MB | +| `openblas.dll` | 27.04 MB | +| `Qt6Gui.dll` | 8.45 MB | +| `libcrypto-3-x64.dll` | 7.08 MB | +| `Qt6Widgets.dll` | 6.24 MB | +| `python311.dll` | 5.91 MB | +| `Qt6Core.dll` | 5.55 MB | + +Reduction compared with the MKL-linked baseline: + +| Path | Before | After | Reduction | +| --- | ---: | ---: | ---: | +| `dist/Groupy` | 772.41 MB | 228.43 MB | 543.98 MB | +| `dist/Groupy/_internal` | 753.50 MB | 213.04 MB | 540.46 MB | +| `dist/Groupy/Groupy.exe` | 18.91 MB | 15.39 MB | 3.52 MB | + +## Interpretation + +- MKL is the dominant size source, accounting for about 538.61 MB. +- Qt/PySide6 is required by the desktop GUI and accounts for about 44.87 MB before Qt transitive DLLs such as ICU. +- RDKit is required by property calculation, group counting, and 2D structure preview. +- `matplotlib` appears in the current `_internal` even though the build script excludes it by default. This likely means the measured `dist/Groupy` folder contains stale files from an older build or an over-collected RDKit subtree. +- Rebuilding from a clean OpenBLAS-based packaging environment removed MKL and stale `matplotlib` contents. The current optimized artifact is about 228.43 MB and has been confirmed to run normally by the user. + +## Optimization Order + +1. Keep using the clean conda-forge packaging environment for release builds. +2. Retest `dist/Groupy/Groupy.exe` after every packaging change. +3. If more reduction is needed, investigate ICU, RDKit, Qt/PySide6, and Tcl/Tk in that order. +4. If RDKit is still over-collected, replace broad `--collect-submodules=rdkit` with a narrower set of required RDKit modules and retest. + +## Commands + +Recommended clean packaging environment: + +```powershell +conda create -n groupy_package -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib pyside6 pyinstaller "libblas=*=*openblas" +conda activate groupy_package +python -m pip install -e . --no-deps +python scripts\build_windows_app.py +``` + +The build script removes `dist/Groupy` before a default onedir build. Use `--no-clean-dist` only when debugging PyInstaller behavior and stale output files are acceptable. diff --git a/README.md b/README.md index 3837611..9146b67 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ python -m pip install -e . --no-deps python scripts\build_windows_app.py ``` -The `_internal` folder contains bundled runtime libraries. Large MKL or BLAS DLLs usually come from the build environment and should not be deleted manually unless the packaged app is retested. +The `_internal` folder contains bundled runtime libraries. Large MKL or BLAS DLLs usually come from the build environment and should not be deleted manually unless the packaged app is retested. Build from the clean conda-forge OpenBLAS packaging environment in `PACKAGING_SIZE_REPORT.md` to avoid bundling Intel MKL. +The build script removes the previous output folder before building so stale `_internal` files do not affect package-size checks. Use `--no-clean-dist` only for debugging. +Current packaging-size findings and optimization order are tracked in `PACKAGING_SIZE_REPORT.md`. Before distributing a packaged app to ordinary users, follow `RELEASE_CHECKLIST.md`. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 190387e..9d2037d 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -255,22 +255,25 @@ Current GUI status: - The user confirmed `dist/Groupy/Groupy.exe` can run normally. - The user confirmed packaged-app validation can run normally after the release checklist pass. - The generated `_internal` folder is currently large, about 753.5 MB, mainly because Intel MKL DLLs are bundled from the build environment. +- `PACKAGING_SIZE_REPORT.md` records the current size baseline: `dist/Groupy` is about 772.41 MB, `_internal` is about 753.50 MB, and MKL DLLs account for about 538.61 MB. +- Rebuilding from a clean conda-forge OpenBLAS packaging environment reduced `dist/Groupy` to about 228.43 MB and `_internal` to about 213.04 MB; MKL DLLs are no longer present. - The build script now excludes clearly unused optional modules by default and supports `--no-default-excludes` / `--exclude-module` for tuning. +- The build script now removes the previous output folder before PyInstaller runs, so stale files do not remain in `_internal` after a rebuild. - README now recommends building from a clean conda-forge packaging environment before trying manual file exclusions. -- `_internal` size optimization is deferred until the user-facing workflow and release checklist are stable. +- `_internal` size optimization has removed MKL by using a clean OpenBLAS-based packaging environment. Further reductions are optional and should be measured against `PACKAGING_SIZE_REPORT.md`. - README documents that OpenBabel conversion, Gaussian input generation, and ASE visualization remain optional workflows outside the default GUI workflow. - `RELEASE_CHECKLIST.md` captures the pre-release build checks, GUI smoke test, clean Windows validation, and packaged app distribution notes. ## Current Known Review Findings -- `_internal` size optimization is intentionally deferred until the release workflow is stable. +- Further `_internal` size optimization is optional and should focus on ICU, RDKit, Qt/PySide6, and Tcl/Tk only after retesting the current OpenBLAS build. - The legacy interactive menu is still present for compatibility, but new automation should use `groupy.api`, `Groupy count`, `Groupy calculate`, or `Groupy convert`. - Advanced GUI pages for OpenBabel conversion, Gaussian input generation, and optional visualization are planned for a later phase. ## Near-Term Next Step -Continue with Phase 9 packaging optimization: +Continue with optional Phase 9 packaging optimization: -1. Measure the current packaged app contents again. -2. Identify the largest removable or avoidable dependencies in `_internal`. -3. Optimize package size in small steps, retesting `dist/Groupy/Groupy.exe` after each change. +1. Preserve the clean OpenBLAS packaging environment as the release build path. +2. If more size reduction is needed, inspect ICU, RDKit, Qt/PySide6, and Tcl/Tk. +3. Optimize remaining large components in small steps, retesting `dist/Groupy/Groupy.exe` after each change. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index a582ed8..81aca1f 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -48,6 +48,7 @@ dist/Groupy/Groupy.exe - Distribute the complete `dist/Groupy` folder. Do not move only `Groupy.exe` away from `_internal`. - Do not delete DLLs from `_internal` unless the packaged app is retested afterward. +- Check `PACKAGING_SIZE_REPORT.md` when comparing package-size changes across builds. ## 4. GUI Smoke Test @@ -86,9 +87,11 @@ Test the final artifact on a Windows machine without the development environment - Package the complete `dist/Groupy` folder as a zip archive. - Include release notes that mention optional dependency boundaries. - Record the build date, source commit, Python version, and build environment. -- Keep `_internal` size optimization as a separate post-release task until the main workflow is stable. +- Build from the clean OpenBLAS packaging environment when preparing release artifacts. +- Treat any further `_internal` size optimization as optional and retest the app after each change. ## 8. Validation Record - 2026-06-25: User confirmed the packaged app validation can run normally. - 2026-06-25: User confirmed the enhanced GUI with import, options, and 2D structure preview looks satisfactory. +- 2026-06-25: User confirmed the OpenBLAS-based rebuilt app can run normally and is much smaller. diff --git a/scripts/build_windows_app.py b/scripts/build_windows_app.py index 88e0823..3b7b066 100644 --- a/scripts/build_windows_app.py +++ b/scripts/build_windows_app.py @@ -6,6 +6,7 @@ import importlib.util import platform import shlex +import shutil import subprocess import sys from pathlib import Path @@ -51,6 +52,11 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument("--console", action="store_true", help="Keep a console window for debugging.") parser.add_argument("--no-clean", action="store_true", help="Do not clear PyInstaller cache before building.") + parser.add_argument( + "--no-clean-dist", + action="store_true", + help="Do not remove the existing output app folder or executable before building.", + ) parser.add_argument( "--no-default-excludes", action="store_true", @@ -72,6 +78,8 @@ def main(argv: list[str] | None = None) -> int: print("PyInstaller command:") print(format_command(command)) print(f"Expected output: {expected_output}") + if not args.no_clean_dist: + print(f"Clean output before build: {output_cleanup_path(args)}") return 0 missing = [ @@ -94,6 +102,9 @@ def main(argv: list[str] | None = None) -> int: file=sys.stderr, ) + if not args.no_clean_dist: + clean_existing_output(args) + completed = subprocess.run(command, cwd=ROOT, check=False) if completed.returncode != 0: return completed.returncode @@ -143,6 +154,24 @@ def expected_output_path(args: argparse.Namespace) -> Path: return args.dist_path / args.name / f"{args.name}.exe" +def output_cleanup_path(args: argparse.Namespace) -> Path: + if args.mode == "onefile": + return args.dist_path / f"{args.name}.exe" + return args.dist_path / args.name + + +def clean_existing_output(args: argparse.Namespace) -> None: + target = output_cleanup_path(args) + resolved_target = target.resolve() + resolved_dist = args.dist_path.resolve() + if resolved_target == resolved_dist or resolved_dist not in resolved_target.parents: + raise RuntimeError(f"Refusing to clean unsafe output path: {target}") + if target.is_dir(): + shutil.rmtree(target) + elif target.exists(): + target.unlink() + + def format_command(command: list[str]) -> str: return shlex.join(str(part) for part in command) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index e606311..1b19916 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -124,6 +124,7 @@ def test_windows_package_script_dry_run_uses_gui_entry(self): self.assertIn("--exclude-module=IPython", completed.stdout) self.assertIn("groupy_gui_entry.py", completed.stdout) self.assertIn("dist", completed.stdout) + self.assertIn("Clean output before build:", completed.stdout) def test_windows_package_script_accepts_custom_excludes(self): script_path = Path(__file__).resolve().parents[1] / "scripts" / "build_windows_app.py" @@ -146,6 +147,15 @@ def test_windows_package_script_accepts_custom_excludes(self): self.assertIn("--exclude-module=example_unused_module", completed.stdout) self.assertNotIn("--exclude-module=matplotlib", completed.stdout) + def test_windows_package_script_cleanup_targets_current_output(self): + from scripts.build_windows_app import output_cleanup_path + + onedir_args = SimpleNamespace(mode="onedir", dist_path=Path("dist"), name="Groupy") + onefile_args = SimpleNamespace(mode="onefile", dist_path=Path("dist"), name="Groupy") + + self.assertEqual(output_cleanup_path(onedir_args), Path("dist") / "Groupy") + self.assertEqual(output_cleanup_path(onefile_args), Path("dist") / "Groupy.exe") + def test_readme_documents_packaged_app_limitations(self): readme_path = Path(__file__).resolve().parents[1] / "README.md" readme = readme_path.read_text(encoding="utf-8") @@ -168,6 +178,19 @@ def test_release_checklist_documents_double_click_distribution(self): self.assertIn("CSV export", checklist) self.assertIn("OpenBabel from conda-forge", checklist) + def test_packaging_size_report_documents_current_baseline(self): + report_path = Path(__file__).resolve().parents[1] / "PACKAGING_SIZE_REPORT.md" + report = report_path.read_text(encoding="utf-8") + + self.assertIn("dist/Groupy/_internal", report) + self.assertIn("MKL DLLs", report) + self.assertIn("538.61 MB", report) + self.assertIn("213.04 MB", report) + self.assertIn("MKL DLLs | 0 | 0.00 MB", report) + self.assertIn("OpenBLAS Rebuild Result", report) + self.assertIn("conda-forge packaging environment", report) + self.assertIn("--no-clean-dist", report) + def test_ci_workflow_runs_build_and_package_checks(self): workflow_path = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "ci.yml" workflow = workflow_path.read_text(encoding="utf-8") From 7bd1d68cc2d9c7618ee2d64ea76f1fd6f0ebfd09 Mon Sep 17 00:00:00 2001 From: 47-5 <1197748182@qq.com> Date: Thu, 25 Jun 2026 12:22:20 +0800 Subject: [PATCH 29/29] Add bilingual user manuals --- README.md | 10 +- manual/USER_MANUAL_en.md | 475 +++++++++++++++++++++++++++++++++++++++ manual/USER_MANUAL_zh.md | 475 +++++++++++++++++++++++++++++++++++++++ tests/test_smoke.py | 18 ++ 4 files changed, 976 insertions(+), 2 deletions(-) create mode 100644 manual/USER_MANUAL_en.md create mode 100644 manual/USER_MANUAL_zh.md diff --git a/README.md b/README.md index 9146b67..e7706a6 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,14 @@ Batch APIs keep processing failed molecules by default for backward compatibilit CI runs the smoke tests, source compilation check, `python -m build`, and a dry-run Windows app packaging command. -### Manual and Documention -The user manual is in the manual folder. Generated API documentation can be found in the doc folder and should be kept separate from source documentation. +### Manual and Documentation + +Current user manuals: + +- Chinese: `manual/USER_MANUAL_zh.md` +- English: `manual/USER_MANUAL_en.md` + +The older background-oriented manual remains in `manual/Groupy_manual.md`. Generated API documentation can be found in the doc folder and should be kept separate from source documentation. # Known limitation diff --git a/manual/USER_MANUAL_en.md b/manual/USER_MANUAL_en.md new file mode 100644 index 0000000..d7943ce --- /dev/null +++ b/manual/USER_MANUAL_en.md @@ -0,0 +1,475 @@ +# Groupy User Manual + +Last updated: 2026-06-25 + +This manual describes the current refactored Groupy workflow, including the desktop GUI, command-line interface, Python API, input formats, optional advanced features, and Windows packaging. The older background-oriented manual remains available at `manual/Groupy_manual.md`. + +## 1. What Groupy Does + +Groupy is a SMILES-based molecular analysis toolkit. It can: + +- Calculate molecular properties from SMILES. +- Count group-contribution groups from SMILES. +- Export tabular results to CSV. +- Show a 2D structure preview in the desktop GUI. +- Import SMILES from `.txt`, `.csv`, and `.xlsx` files. +- Be used as a Python package in scripts or other applications. +- Optionally use OpenBabel for file conversion and Gaussian input generation. + +Ordinary users should start with the desktop GUI. Script users and developers can use the CLI or Python API. + +## 2. Recommended Usage + +### 2.1 Ordinary Users + +If you received a packaged Windows app, open: + +```text +dist/Groupy/Groupy.exe +``` + +Double-click `Groupy.exe` to start the app. Do not copy only `Groupy.exe`; keep the entire `dist/Groupy` folder because `_internal` contains the Python runtime, RDKit, Qt, OpenBLAS, and other required libraries. + +### 2.2 Developers And Script Users + +If you run from source, create an isolated environment: + +```powershell +conda create -n groupy_env -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib +conda activate groupy_env +python -m pip install -e . +``` + +For GUI support: + +```powershell +python -m pip install -e ".[gui]" +``` + +For Windows app packaging: + +```powershell +python -m pip install -e ".[gui,package]" +``` + +For OpenBabel-dependent features: + +```powershell +conda install -c conda-forge openbabel +``` + +Avoid `pip install openbabel`, especially on Windows. + +## 3. Desktop GUI + +Start the GUI with: + +```powershell +Groupy-GUI +``` + +Or double-click the packaged `dist/Groupy/Groupy.exe`. + +### 3.1 Enter SMILES + +Enter one or more SMILES strings in the SMILES editor, one per line: + +```text +C1CCCC1 +CCO +CC(C)C +``` + +The first valid SMILES in the editor is shown in the 2D structure preview. + +### 3.2 Import SMILES From A File + +Click `Import File` and choose a SMILES input file. Supported formats: + +- `.txt` +- `.csv` +- `.xlsx` + +A `.txt` file should contain one SMILES per line: + +```text +C1CCCC1 +CCO +CC(C)C +``` + +`.csv` and `.xlsx` files must contain a column named `smiles`: + +```text +smiles +C1CCCC1 +CCO +CC(C)C +``` + +After import, the SMILES values are inserted into the editor. + +### 3.3 2D Structure Preview + +The right-hand preview panel shows the 2D structure for a SMILES string. + +- While editing input, it previews the first valid SMILES. +- After calculation or group counting, selecting a table row updates the preview to that molecule. +- If a SMILES string cannot be parsed, the preview shows `Invalid SMILES`. + +This feature uses RDKit only. It does not require OpenBabel or ASE. + +### 3.4 Calculate Molecular Properties + +Click `Calculate Properties` to calculate properties. Results appear in the table and can be exported to CSV. + +Calculation options: + +- `Parameters` + - `Stepwise`: recommended default. + - `Simultaneous`: available for compatibility, but may be unreliable for some molecules. +- `Hydrocarbon check` + - Enabled: combustion-related properties are calculated only for hydrocarbons. + - Disabled: Groupy attempts to calculate those properties for non-hydrocarbons too, but the results may not be physically meaningful. + +### 3.5 Count Groups + +Click `Count Groups` to count group-contribution groups. + +Counting options: + +- `Show zero groups` + - Enabled: include all groups, including zero-count groups. + - Disabled: include only nonzero groups. +- `Include SMILES` + - Enabled: include a SMILES column in the output. + +### 3.6 Export CSV + +After calculation or group counting, click `Export CSV` to save the current table. + +The exported CSV can be opened with Excel, WPS, LibreOffice, or Python/pandas. + +### 3.7 GUI Troubleshooting + +If running from source and PySide6 is missing: + +```powershell +python -m pip install -e ".[gui]" +``` + +If the packaged executable does not start: + +- Make sure the whole `dist/Groupy` folder is present. +- Make sure `_internal` is next to `Groupy.exe`. +- Try copying `dist/Groupy` to a simple path without unusual characters. +- Run `dist\Groupy\Groupy.exe` from a terminal to see error messages. + +## 4. Command-Line Interface + +After installation, the `Groupy` command is available. + +### 4.1 Help + +```powershell +Groupy --help +Groupy count --help +Groupy calculate --help +Groupy convert --help +``` + +### 4.2 Count Groups For One SMILES + +```powershell +Groupy count --smiles C1CCCC1 +``` + +Example JSON output: + +```json +{"f_168": 5, "smiles": "C1CCCC1"} +``` + +Write CSV output: + +```powershell +Groupy count --smiles C1CCCC1 --output count.csv +``` + +Include zero-count groups: + +```powershell +Groupy count --smiles C1CCCC1 --include-zero --output count_full.csv +``` + +### 4.3 Calculate Properties For One SMILES + +```powershell +Groupy calculate --smiles C1CCCC1 +``` + +Write CSV output: + +```powershell +Groupy calculate --smiles C1CCCC1 --output calculate.csv +``` + +Select parameter type: + +```powershell +Groupy calculate --smiles C1CCCC1 --parameter-type step_wise +Groupy calculate --smiles C1CCCC1 --parameter-type simultaneous +``` + +Disable hydrocarbon filtering: + +```powershell +Groupy calculate --smiles CCO --no-check-hydrocarbon +``` + +### 4.4 Batch Calculation + +Input files can be `.txt`, `.csv`, or `.xlsx`: + +```powershell +Groupy calculate --input SMILES.txt --output calculate.csv +Groupy count --input SMILES.txt --output count.csv +``` + +`.csv` and `.xlsx` files must contain a `smiles` column. + +### 4.5 File Conversion + +Single-file conversion: + +```powershell +Groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2 +``` + +This requires OpenBabel: + +```powershell +conda install -c conda-forge openbabel +``` + +Conversion is an optional advanced workflow and is not part of the default GUI workflow. + +### 4.6 Legacy Interactive Menu + +Running: + +```powershell +Groupy +``` + +starts the legacy interactive menu. New scripts and automation should prefer: + +- `Groupy count` +- `Groupy calculate` +- `Groupy convert` +- `groupy.api` + +## 5. Python API + +Groupy can be used as a Python library. + +### 5.1 Single-Molecule Calls + +```python +from groupy.api import calculate_smiles, count_smiles + +properties = calculate_smiles("C1CCCC1") +groups = count_smiles("C1CCCC1") + +print(properties) +print(groups) +``` + +### 5.2 Batch Calls + +```python +from groupy.api import calculate_many_smiles, count_many_smiles, write_records_csv + +smiles_values = ["C1CCCC1", "CCO", "CC(C)C"] + +properties = calculate_many_smiles(smiles_values) +groups = count_many_smiles(smiles_values) + +write_records_csv(properties, "calculate.csv") +write_records_csv(groups, "count.csv") +``` + +### 5.3 Load SMILES From A File + +```python +from groupy.io import load_smiles_file + +smiles_values = load_smiles_file("SMILES.xlsx") +``` + +`.csv` and `.xlsx` files must contain a `smiles` column. + +### 5.4 Lower-Level Classes + +For compatibility or finer control, use the lower-level classes: + +```python +from groupy.gp_calculator import Calculator +from groupy.gp_counter import Counter + +calculator = Calculator() +counter = Counter() + +calculator.calculate_mols( + "SMILES.txt", + properties_file_path="calculate.csv", + verbose=False, +) + +counter.count_mols( + "SMILES.txt", + count_result_file_path="count.csv", + add_smiles=True, + verbose=False, +) +``` + +Batch APIs continue after invalid SMILES by default for backward compatibility. To stop on the first error: + +```python +calculator.calculate_mols( + "SMILES.txt", + properties_file_path="calculate.csv", + continue_on_error=False, + verbose=False, +) +``` + +## 6. Optional Advanced Features + +### 6.1 OpenBabel Conversion + +OpenBabel-dependent features include: + +- Generating `.xyz` files from SMILES. +- Converting between common molecular file formats. +- Extracting SMILES from structure files. +- Supporting coordinate generation for Gaussian input files. + +Install OpenBabel with: + +```powershell +conda install -c conda-forge openbabel +``` + +### 6.2 Gaussian Input Generation + +`groupy.gp_generator.Generator` can generate Gaussian `.gjf` files from SMILES. This workflow usually depends on OpenBabel for 3D coordinate generation. + +This is currently an advanced scripting workflow, not part of the default GUI workflow. + +### 6.3 Molecular Visualization + +The legacy Viewer depends on ASE: + +```powershell +python -m pip install -e ".[viewer]" +``` + +Or: + +```powershell +conda install -c conda-forge ase +``` + +The current GUI 2D structure preview does not require ASE. + +## 7. Build A Windows App + +To distribute a double-clickable Windows app, use a clean conda-forge OpenBLAS packaging environment: + +```powershell +conda create -n groupy_package -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib pyside6 pyinstaller "libblas=*=*openblas" +conda activate groupy_package +python -m pip install -e . --no-deps +python scripts\build_windows_app.py +``` + +Default output: + +```text +dist/Groupy/Groupy.exe +``` + +Distribute the entire `dist/Groupy` folder, not just the executable. + +The build script removes the previous `dist/Groupy` output by default so stale `_internal` files do not affect package size. Use this only for PyInstaller debugging: + +```powershell +python scripts\build_windows_app.py --no-clean-dist +``` + +Package size notes: + +```text +PACKAGING_SIZE_REPORT.md +``` + +Release checklist: + +```text +RELEASE_CHECKLIST.md +``` + +## 8. Known Limitations + +- The `simultaneous` parameter type may be unreliable for some molecules. The recommended default is `step_wise`. +- Combustion enthalpy, heat value, and specific impulse are designed mainly for hydrocarbons. Interpret non-hydrocarbon results carefully. +- OpenBabel conversion, Gaussian input generation, and ASE visualization are optional advanced workflows and are not part of the default GUI workflow. +- The packaged `_internal` folder contains runtime dependencies. Do not delete DLLs manually unless the executable is fully retested. +- More advanced GUI pages, such as OpenBabel conversion and Gaussian input generation, can be added in a later phase. + +## 9. Troubleshooting + +### 9.1 SMILES Cannot Be Parsed + +Check: + +- Extra spaces or invisible characters. +- Ring closure digits. +- Atom symbol capitalization. +- Correct lowercase aromatic atoms. + +### 9.2 CSV Or XLSX Import Fails + +Make sure the file contains a lowercase `smiles` column: + +```text +smiles +``` + +### 9.3 Packaged App Is Too Large + +Use the OpenBLAS packaging environment: + +```powershell +conda create -n groupy_package -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib pyside6 pyinstaller "libblas=*=*openblas" +``` + +If `_internal` contains many `mkl_*.dll` files, the build environment is still linked against Intel MKL. + +### 9.4 GUI Does Not Start + +When running from source: + +```powershell +Groupy-GUI --check +``` + +If PySide6 is missing: + +```powershell +python -m pip install -e ".[gui]" +``` + +For packaged apps, first check that the entire `dist/Groupy` folder is complete. diff --git a/manual/USER_MANUAL_zh.md b/manual/USER_MANUAL_zh.md new file mode 100644 index 0000000..80f25a7 --- /dev/null +++ b/manual/USER_MANUAL_zh.md @@ -0,0 +1,475 @@ +# Groupy 用户手册 + +更新日期:2026-06-25 + +本手册面向当前重构后的 Groupy,重点介绍普通用户的桌面 GUI、命令行、Python API、文件格式和 Windows 打包发布方式。旧版背景说明和更详细的算法介绍仍保留在 `manual/Groupy_manual.md`。 + +## 1. Groupy 可以做什么 + +Groupy 是一个基于 SMILES 的分子分析工具,主要功能包括: + +- 根据 SMILES 计算分子性质。 +- 根据 SMILES 统计基团贡献方法中的基团数量。 +- 将结果导出为 CSV 文件。 +- 在桌面 GUI 中查看 SMILES 对应的 2D 分子结构。 +- 从 `.txt`、`.csv`、`.xlsx` 文件批量导入 SMILES。 +- 作为 Python 包被其他脚本或 GUI 调用。 +- 可选地使用 OpenBabel 做格式转换和 Gaussian 输入文件生成。 + +普通用户推荐使用桌面 GUI;脚本用户和开发者可以使用命令行或 Python API。 + +## 2. 推荐使用方式 + +### 2.1 普通用户 + +如果你拿到的是打包后的 Windows 程序,请进入: + +```text +dist/Groupy/Groupy.exe +``` + +双击 `Groupy.exe` 启动。不要只复制 `Groupy.exe`,必须保留整个 `dist/Groupy` 文件夹,因为 `_internal` 中包含运行所需的 Python、RDKit、Qt、OpenBLAS 等库。 + +### 2.2 开发者或脚本用户 + +如果你从源码运行,推荐创建独立环境: + +```powershell +conda create -n groupy_env -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib +conda activate groupy_env +python -m pip install -e . +``` + +如果需要 GUI: + +```powershell +python -m pip install -e ".[gui]" +``` + +如果需要打包 Windows 应用: + +```powershell +python -m pip install -e ".[gui,package]" +``` + +如果需要 OpenBabel 相关功能: + +```powershell +conda install -c conda-forge openbabel +``` + +不要使用 `pip install openbabel`,它在 Windows 上通常不可靠。 + +## 3. 桌面 GUI 使用说明 + +启动 GUI: + +```powershell +Groupy-GUI +``` + +或者双击打包后的 `dist/Groupy/Groupy.exe`。 + +### 3.1 输入 SMILES + +在左上方 SMILES 输入框中输入一个或多个 SMILES,每行一个。例如: + +```text +C1CCCC1 +CCO +CC(C)C +``` + +输入框中第一个有效 SMILES 会显示在右侧 2D 结构预览区域。 + +### 3.2 从文件导入 SMILES + +点击 `Import File` 选择输入文件。支持: + +- `.txt` +- `.csv` +- `.xlsx` + +`.txt` 文件每行一个 SMILES: + +```text +C1CCCC1 +CCO +CC(C)C +``` + +`.csv` 和 `.xlsx` 文件必须包含名为 `smiles` 的列: + +```text +smiles +C1CCCC1 +CCO +CC(C)C +``` + +导入后,文件中的 SMILES 会填入输入框。 + +### 3.3 2D 结构预览 + +GUI 右侧会显示 SMILES 对应的 2D 结构图。 + +- 输入框改变时,预览第一个有效 SMILES。 +- 计算或统计后,点击结果表中的某一行,预览会切换到该行对应的分子。 +- 如果 SMILES 无法解析,预览区会显示 `Invalid SMILES`。 + +该功能使用 RDKit 生成 2D 图,不需要 OpenBabel 或 ASE。 + +### 3.4 计算分子性质 + +点击 `Calculate Properties` 计算性质。结果会显示在表格中,并可导出为 CSV。 + +计算选项: + +- `Parameters` + - `Stepwise`:默认推荐选项。 + - `Simultaneous`:保留为可选参数类型;当前已知可能在部分分子上产生不可靠结果。 +- `Hydrocarbon check` + - 勾选时,燃烧焓、热值、比冲等仅在烃类分子上计算。 + - 取消勾选时,会尝试对非烃类分子也计算这些性质,但结果可能没有物理意义。 + +### 3.5 统计基团数量 + +点击 `Count Groups` 统计基团数量。 + +统计选项: + +- `Show zero groups` + - 勾选后,结果中会显示所有基团,包括数量为 0 的基团。 + - 不勾选时,只显示非零基团。 +- `Include SMILES` + - 勾选后,结果中包含原始或规范化后的 SMILES 列。 + +### 3.6 导出 CSV + +计算或统计完成后,点击 `Export CSV` 保存结果。 + +CSV 文件可用 Excel、WPS、LibreOffice 或 Python/pandas 打开。 + +### 3.7 GUI 常见问题 + +如果从源码运行 GUI 时出现 PySide6 缺失提示: + +```powershell +python -m pip install -e ".[gui]" +``` + +如果双击打包后的 exe 无反应: + +- 确认没有只复制 `Groupy.exe`。 +- 确认 `_internal` 文件夹仍在 `Groupy.exe` 同级目录。 +- 尝试把整个 `dist/Groupy` 文件夹复制到没有中文或特殊字符的路径下测试。 +- 从命令行运行 `dist\Groupy\Groupy.exe` 查看错误信息。 + +## 4. 命令行使用 + +安装后会提供 `Groupy` 命令。 + +### 4.1 查看帮助 + +```powershell +Groupy --help +Groupy count --help +Groupy calculate --help +Groupy convert --help +``` + +### 4.2 统计单个 SMILES 的基团 + +```powershell +Groupy count --smiles C1CCCC1 +``` + +输出为 JSON,例如: + +```json +{"f_168": 5, "smiles": "C1CCCC1"} +``` + +导出 CSV: + +```powershell +Groupy count --smiles C1CCCC1 --output count.csv +``` + +显示零值基团: + +```powershell +Groupy count --smiles C1CCCC1 --include-zero --output count_full.csv +``` + +### 4.3 计算单个 SMILES 的性质 + +```powershell +Groupy calculate --smiles C1CCCC1 +``` + +导出 CSV: + +```powershell +Groupy calculate --smiles C1CCCC1 --output calculate.csv +``` + +指定参数类型: + +```powershell +Groupy calculate --smiles C1CCCC1 --parameter-type step_wise +Groupy calculate --smiles C1CCCC1 --parameter-type simultaneous +``` + +取消烃类检查: + +```powershell +Groupy calculate --smiles CCO --no-check-hydrocarbon +``` + +### 4.4 批量计算 + +输入文件可以是 `.txt`、`.csv` 或 `.xlsx`。 + +```powershell +Groupy calculate --input SMILES.txt --output calculate.csv +Groupy count --input SMILES.txt --output count.csv +``` + +`.csv` 和 `.xlsx` 文件必须包含 `smiles` 列。 + +### 4.5 文件格式转换 + +单文件转换命令: + +```powershell +Groupy convert --input molecule.xyz --from xyz --to mol2 --output molecule.mol2 +``` + +该功能需要 OpenBabel: + +```powershell +conda install -c conda-forge openbabel +``` + +转换功能不是默认 GUI 主流程的一部分。 + +### 4.6 旧交互菜单 + +直接运行: + +```powershell +Groupy +``` + +会进入旧版交互菜单。新脚本和自动化流程建议优先使用: + +- `Groupy count` +- `Groupy calculate` +- `Groupy convert` +- `groupy.api` + +## 5. Python API + +Groupy 可以作为 Python 包使用。 + +### 5.1 单分子计算 + +```python +from groupy.api import calculate_smiles, count_smiles + +properties = calculate_smiles("C1CCCC1") +groups = count_smiles("C1CCCC1") + +print(properties) +print(groups) +``` + +### 5.2 批量计算 + +```python +from groupy.api import calculate_many_smiles, count_many_smiles, write_records_csv + +smiles_values = ["C1CCCC1", "CCO", "CC(C)C"] + +properties = calculate_many_smiles(smiles_values) +groups = count_many_smiles(smiles_values) + +write_records_csv(properties, "calculate.csv") +write_records_csv(groups, "count.csv") +``` + +### 5.3 从文件读取 SMILES + +```python +from groupy.io import load_smiles_file + +smiles_values = load_smiles_file("SMILES.xlsx") +``` + +`.csv` 和 `.xlsx` 文件必须包含 `smiles` 列。 + +### 5.4 使用底层类 + +如果需要旧 API 或更细控制,可以直接使用底层类: + +```python +from groupy.gp_calculator import Calculator +from groupy.gp_counter import Counter + +calculator = Calculator() +counter = Counter() + +calculator.calculate_mols( + "SMILES.txt", + properties_file_path="calculate.csv", + verbose=False, +) + +counter.count_mols( + "SMILES.txt", + count_result_file_path="count.csv", + add_smiles=True, + verbose=False, +) +``` + +默认情况下,批处理遇到坏 SMILES 会继续执行并记录失败信息。如果希望遇到第一个错误就停止: + +```python +calculator.calculate_mols( + "SMILES.txt", + properties_file_path="calculate.csv", + continue_on_error=False, + verbose=False, +) +``` + +## 6. 可选进阶功能 + +### 6.1 OpenBabel 转换 + +OpenBabel 相关功能包括: + +- SMILES 生成 `.xyz`。 +- 常见分子文件格式转换。 +- 从结构文件提取 SMILES。 +- Gaussian 输入文件生成中的结构转换步骤。 + +安装: + +```powershell +conda install -c conda-forge openbabel +``` + +### 6.2 Gaussian 输入文件生成 + +`groupy.gp_generator.Generator` 可根据 SMILES 生成 Gaussian `.gjf` 文件。该流程通常依赖 OpenBabel 生成三维坐标。 + +这部分目前属于进阶脚本功能,不是默认 GUI 主流程。 + +### 6.3 分子可视化 + +旧版 Viewer 依赖 ASE: + +```powershell +python -m pip install -e ".[viewer]" +``` + +或: + +```powershell +conda install -c conda-forge ase +``` + +当前 GUI 的 2D 结构预览不依赖 ASE。 + +## 7. 打包 Windows 应用 + +如果需要给普通用户发布双击运行的 Windows 应用,推荐使用干净的 conda-forge + OpenBLAS 打包环境: + +```powershell +conda create -n groupy_package -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib pyside6 pyinstaller "libblas=*=*openblas" +conda activate groupy_package +python -m pip install -e . --no-deps +python scripts\build_windows_app.py +``` + +默认输出: + +```text +dist/Groupy/Groupy.exe +``` + +发布时请压缩并分发整个 `dist/Groupy` 文件夹,不要只分发 exe。 + +打包脚本默认会删除旧的 `dist/Groupy` 输出目录,以避免旧 `_internal` 文件残留影响体积。只有调试 PyInstaller 时才建议使用: + +```powershell +python scripts\build_windows_app.py --no-clean-dist +``` + +体积优化记录见: + +```text +PACKAGING_SIZE_REPORT.md +``` + +发布前检查清单见: + +```text +RELEASE_CHECKLIST.md +``` + +## 8. 已知限制 + +- `simultaneous` 参数类型在部分分子上可能产生不可靠结果,默认建议使用 `step_wise`。 +- 燃烧焓、热值、比冲等性质主要针对烃类分子设计;非烃分子的结果需要谨慎解释。 +- OpenBabel 转换、Gaussian 输入文件生成、ASE 可视化属于可选进阶功能,不是默认 GUI 主流程。 +- 打包后的 `_internal` 是运行时依赖目录,不应手动删除其中 DLL,除非重新完整测试 exe。 +- 进一步的 GUI 高级页面,例如 OpenBabel 转换和 Gaussian 输入生成,可以作为后续阶段开发。 + +## 9. 故障排查 + +### 9.1 无法解析 SMILES + +检查: + +- 是否有空格或不可见字符。 +- 环闭合数字是否成对。 +- 原子符号大小写是否正确。 +- 芳香原子是否使用正确小写形式。 + +### 9.2 CSV 或 XLSX 无法导入 + +检查文件中是否有名为 `smiles` 的列。列名区分大小写,建议使用小写: + +```text +smiles +``` + +### 9.3 打包后体积过大 + +优先确认是否使用了 OpenBLAS 打包环境: + +```powershell +conda create -n groupy_package -c conda-forge python=3.11 rdkit pandas numpy openpyxl tqdm joblib pyside6 pyinstaller "libblas=*=*openblas" +``` + +如果 `_internal` 中存在大量 `mkl_*.dll`,说明构建环境仍然链接了 Intel MKL。 + +### 9.4 GUI 启动失败 + +源码运行时检查: + +```powershell +Groupy-GUI --check +``` + +如果提示缺少 PySide6: + +```powershell +python -m pip install -e ".[gui]" +``` + +打包应用启动失败时,优先确认整个 `dist/Groupy` 文件夹是否完整。 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1b19916..91b711e 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -191,6 +191,24 @@ def test_packaging_size_report_documents_current_baseline(self): self.assertIn("conda-forge packaging environment", report) self.assertIn("--no-clean-dist", report) + def test_user_manuals_document_current_gui_and_packaging_workflows(self): + root = Path(__file__).resolve().parents[1] + zh_manual = (root / "manual" / "USER_MANUAL_zh.md").read_text(encoding="utf-8") + en_manual = (root / "manual" / "USER_MANUAL_en.md").read_text(encoding="utf-8") + readme = (root / "README.md").read_text(encoding="utf-8") + + for manual in (zh_manual, en_manual): + self.assertIn("Groupy-GUI", manual) + self.assertIn("Import File", manual) + self.assertIn("2D", manual) + self.assertIn("Groupy calculate --smiles C1CCCC1", manual) + self.assertIn("PACKAGING_SIZE_REPORT.md", manual) + self.assertIn("libblas=*=*openblas", manual) + self.assertIn("OpenBabel", manual) + + self.assertIn("manual/USER_MANUAL_zh.md", readme) + self.assertIn("manual/USER_MANUAL_en.md", readme) + def test_ci_workflow_runs_build_and_package_checks(self): workflow_path = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "ci.yml" workflow = workflow_path.read_text(encoding="utf-8")