From f08140e44ca6c7b3cdc9cfd244988d06a7199acc Mon Sep 17 00:00:00 2001 From: Paul Saxe Date: Wed, 24 Apr 2024 16:32:25 -0400 Subject: [PATCH 1/2] Finalized support for Docker. --- .github/workflows/Release.yaml | 11 ++++ HISTORY.rst | 6 +++ Makefile | 8 ++- devtools/docker/Dockerfile | 19 +++++++ devtools/docker/environment.yml | 10 ++++ dftbplus_step/base.py | 58 ++++---------------- dftbplus_step/choose_parameters.py | 2 +- dftbplus_step/data/configuration.txt | 23 -------- dftbplus_step/data/dftbplus.ini | 80 ++++++++++++++++++++++++++++ dftbplus_step/dftbplus.py | 73 ++++++++++++++++++++----- dftbplus_step/dos.py | 2 +- dftbplus_step/energy.py | 4 +- dftbplus_step/installer.py | 75 ++++++++++++++------------ 13 files changed, 251 insertions(+), 120 deletions(-) create mode 100644 devtools/docker/Dockerfile create mode 100644 devtools/docker/environment.yml create mode 100644 dftbplus_step/data/dftbplus.ini diff --git a/.github/workflows/Release.yaml b/.github/workflows/Release.yaml index 0eeeddf..f0d6348 100644 --- a/.github/workflows/Release.yaml +++ b/.github/workflows/Release.yaml @@ -13,3 +13,14 @@ jobs: with: src : dftbplus_step secrets: inherit + + docker: + name: Docker + needs: release + uses: molssi-seamm/devops/.github/workflows/Docker.yaml@main + with: + image : molssi-seamm/seamm-dftbplus + description: A DFTB+ executable packaged for use with SEAMM or standalone + # Can limit platforms, e.g., linux/amd64, linux/arm64 + # platforms: linux/amd64 + secrets: inherit diff --git a/HISTORY.rst b/HISTORY.rst index a05ad52..776a2bc 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,12 @@ ======= History ======= +2024.4.24 -- Finalized support for Docker containers + * Fixed issues and tested running in containers. + * Add CI to make a Docker image for DFTB+ + * Fixed issue with changes in input for DFTB+: CalculateGradients has become + PrintGradients it seems. + 2024.1.18 -- Support for running in containers and writing input only. * Added new property: scaled dipole. * Added option to write the input file and not run DFTB+ diff --git a/Makefile b/Makefile index 310d62f..e1227c1 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ MODULE := dftbplus_step .PHONY: help clean clean-build clean-docs clean-pyc clean-test lint format typing test .PHONY: dependencies test-all coverage html docs servedocs release check-release -.PHONY: dist install uninstall +.PHONY: dist install uninstall image uninstall-image .DEFAULT_GOAL := help define BROWSER_PYSCRIPT @@ -104,3 +104,9 @@ install: uninstall ## install the package to the active Python's site-packages uninstall: clean ## uninstall the package pip uninstall --yes $(MODULE) + +image: uninstall-image ## Make the Docker image + cd devtools/docker && docker image build -t ghcr.io/molssi-seamm/seamm-dftbplus:latest . + +uninstall-image: ## Remove the docker image + docker image rm --force ghcr.io/molssi-seamm/seamm-dftbplus:latest diff --git a/devtools/docker/Dockerfile b/devtools/docker/Dockerfile new file mode 100644 index 0000000..c2c6f2c --- /dev/null +++ b/devtools/docker/Dockerfile @@ -0,0 +1,19 @@ +FROM molssi/mamba141 + +LABEL org.opencontainers.image.authors="psaxe@molssi.org" + +COPY ./environment.yml /root/environment.yml + +RUN mamba env update -f /root/environment.yml + +RUN apt-get install -qy curl + +RUN mkdir /root/Parameters \ + && cd /root/Parameters \ + && curl 'https://dftb.org/fileadmin/DFTB/public/slako-unpacked.tar.xz' -o slako-unpacked.tar.xz \ + && tar -x -o -p -f slako-unpacked.tar.xz \ + && chmod -R go+rX . \ + && rm slako-unpacked.tar.xz + +WORKDIR /home +CMD ["dftb+"] diff --git a/devtools/docker/environment.yml b/devtools/docker/environment.yml new file mode 100644 index 0000000..e58b849 --- /dev/null +++ b/devtools/docker/environment.yml @@ -0,0 +1,10 @@ +name: base +channels: + - conda-forge +dependencies: + - python + # Executables, etc. + - dftbplus + - dftbplus-tools + - dftbplus-python + diff --git a/dftbplus_step/base.py b/dftbplus_step/base.py index b27813d..dfec092 100644 --- a/dftbplus_step/base.py +++ b/dftbplus_step/base.py @@ -107,6 +107,11 @@ def model(self): def model(self, value): self.parent.model = value + @property + def exe_config(self): + """The configuration for the DFTB+ executable.""" + return self.parent.exe_config + def band_structure( self, input_path, sym_points, sym_names, Efermi=[0.0, 0.0], DOS=None ): @@ -158,13 +163,6 @@ def create_band_structure_data( full_config.read(ini_dir / "dftbplus.ini") executor = self.parent.flowchart.executor - executor_type = executor.name - if executor_type not in full_config: - raise RuntimeError( - f"No section for '{executor_type}' in DFTB+ ini file " - f"({ini_dir / 'dftbplus.ini'})" - ) - config = dict(full_config.items(executor_type)) if spin_polarized: cmd = ["dp_bands", "-s", input_path, "band"] @@ -173,7 +171,7 @@ def create_band_structure_data( result = executor.run( cmd=cmd, - config=config, + config=self.exe_config, directory=self.directory, files={}, return_files=["*"], @@ -271,34 +269,16 @@ def create_dos_data(self, input_path, Efermi=[0.0]): logger.info("Preparing DOS") - seamm_options = self.parent.global_options - # Total DOS executor = self.parent.flowchart.executor - # Read configuration file for DFTB+ - ini_dir = Path(seamm_options["root"]).expanduser() - full_config = configparser.ConfigParser() - full_config.read(ini_dir / "dftbplus.ini") - executor_type = executor.name - if executor_type not in full_config: - raise RuntimeError( - f"No section for '{executor_type}' in DFTB+ ini file " - f"({ini_dir / 'dftbplus.ini'})" - ) - config = dict(full_config.items(executor_type)) - result = executor.run( cmd=[ "dp_dos", str(input_path), "dos_total.dat", - ">", - "DOS.out", - "2>", - "dos_stderr.txt", ], - config=config, + config=self.exe_config, directory=self.directory, files={}, return_files=["*"], @@ -369,12 +349,8 @@ def create_dos_data(self, input_path, Efermi=[0.0]): "-w", str(path), str(out), - ">", - "DOS.out", - "2>", - "dos_stderr.txt", ], - config=config, + config=self.exe_config, directory=self.directory, files={}, return_files=["*"], @@ -788,26 +764,14 @@ def run(self, current_input): "results.tag", "*.xml", "eigenvec.bin", - ] # yapf: disable + ] # Run the calculation executor = self.parent.flowchart.executor - # Read configuration file for DFTB+ - ini_dir = Path(seamm_options["root"]).expanduser() - full_config = configparser.ConfigParser() - full_config.read(ini_dir / "dftbplus.ini") - executor_type = executor.name - if executor_type not in full_config: - raise RuntimeError( - f"No section for '{executor_type}' in DFTB+ ini file " - f"({ini_dir / 'dftbplus.ini'})" - ) - config = dict(full_config.items(executor_type)) - result = executor.run( cmd=["{code}", ">", "DFTB+.out", "2>", "stderr.txt"], - config=config, + config=self.exe_config, directory=self.directory, files=files, return_files=return_files, @@ -816,7 +780,7 @@ def run(self, current_input): env=env, ) - if result is None: + if not result: logger.error("There was an error running DFTB+") return None diff --git a/dftbplus_step/choose_parameters.py b/dftbplus_step/choose_parameters.py index 297c396..73c4cbf 100644 --- a/dftbplus_step/choose_parameters.py +++ b/dftbplus_step/choose_parameters.py @@ -74,7 +74,7 @@ def get_input(self): # Get the metadata for the Slater-Koster parameters metadata = self.parent._metadata - slako_dir = Path(self.parent.options["slako_dir"]).expanduser() + slako_dir = Path(self.exe_config["slako-dir"]).expanduser() # Create the directory directory = Path(self.directory) diff --git a/dftbplus_step/data/configuration.txt b/dftbplus_step/data/configuration.txt index efb4383..98015cf 100644 --- a/dftbplus_step/data/configuration.txt +++ b/dftbplus_step/data/configuration.txt @@ -1,27 +1,4 @@ [dftbplus-step] - -# Information about where/how the executables are installed -# installation may be 'user', 'conda' or 'module'. If a module is -# specified it will be loaded and those executables used. In this -# case, any path specified using dftbplus-path will be ignored. - -installation = -conda-environment = -modules = - -# The path to the executable. Can be empty or not present, in which -# case the default PATH is used. If a path is given, dftb+ -# from this location will be used. -# -# Ignored if a module is used. The default is to use the PATH -# environment variable. - -dftbplus-path = - -# The path to the Slater-Koster functions - -slako-dir = - # Whether to use MPI to run parallel # use-mpi = False diff --git a/dftbplus_step/data/dftbplus.ini b/dftbplus_step/data/dftbplus.ini new file mode 100644 index 0000000..4f2641b --- /dev/null +++ b/dftbplus_step/data/dftbplus.ini @@ -0,0 +1,80 @@ +# Configuration options for how to run DFTB+ + +[docker] +# The code to use. This may maybe more than just the name of the code, and variables in +# braces {} will be expanded. For example: +# code = mpiexec -np {NTASKS} lmp_mpi +# would expand {NTASKS} to the number of tasks and run the command + +code = dftb+ + +# The name and location of the Docker container to use, optionally with the version + +container = ghcr.io/molssi-seamm/seamm-dftbplus:{version} + +# In addition, you can specify the platform to use. This is useful on e.g. Macs with +# app silicon (M1, M3...) where the default platform is linux/arm64 but some containers +# are only available for linux/amd64. + +# platform = linux/amd64 + +# The path to the Slater-Koster functions + +slako-dir = /root/Parameters/slako + + +[local] +# The type of local installation to use. Options are: +# conda: Use a conda environment +# modules: Use the modules system +# local: Use a local installation +# docker: Use a Docker container +# By default SEAMM installs DFTB+ using conda. + +installation = conda + +# The command line to use, which should start with the executable followed by any options. +# Variables in braces {} will be expanded. For example: +# +# code = mpiexec -np {NTASKS} lmp_mpi +# +# would expand {NTASKS} to the number of tasks and run the command. +# For a 'local' installation, the command line should include the full path to the +# executable or it should be in the path. + +code = dftb+ + +######################### conda section ############################ +# The full path to the conda executable: + +# conda = + +# The Conda environment to use. This is either the name or full path. + +conda-environment = seamm-dftbplus + +######################### modules section ############################ +# The modules to load to run DFTB+, as a list of strings. +# For example, to load the modules dftbplus and openmpi, you would use: +# modules = dftbplus openmpi + +# modules = + +######################### local section ############################ +# The full path to the DFTB+ executable should be in the 'code' option. + +######################### docker section ############################ +# The name and location of the Docker container to use, optionally with the version. +# {version} will be expanded to the version of the plug-in. + +# container = ghcr.io/molssi-seamm/seamm-dftbplus:{version} + +# In addition, you can specify the platform to use. This is useful on e.g. Macs with +# app silicon (M1, M3...) where the default platform is linux/arm64 but some containers +# are only available for linux/amd64. + +# platform = linux/amd64 + +# The path to the Slater-Koster functions + +slako-dir = ~/SEAMM/Parameters/slako diff --git a/dftbplus_step/dftbplus.py b/dftbplus_step/dftbplus.py index 5ffa130..5194c6f 100644 --- a/dftbplus_step/dftbplus.py +++ b/dftbplus_step/dftbplus.py @@ -4,16 +4,13 @@ """ import collections.abc - -try: - import importlib.metadata as implib -except Exception: - import importlib_metadata as implib +import configparser +import importlib import json import logging from pathlib import Path -import pkg_resources import pprint # noqa: F401 +import shutil import sys import molsystem @@ -36,8 +33,8 @@ printer = printing.getPrinter("DFTB+") # Add DFTB+'s properties to the standard properties -path = Path(pkg_resources.resource_filename(__name__, "data/")) -csv_file = path / "properties.csv" +resources = importlib.resources.files("dftbplus_step") / "data" +csv_file = resources / "properties.csv" molsystem.add_properties_from_file(csv_file) @@ -238,14 +235,15 @@ def __init__( self.parameters = dftbplus_step.DftbplusParameters() # Get the metadata for the Slater-Koster parameters - package = self.__module__.split(".")[0] - files = [p for p in implib.files(package) if p.name == "metadata.json"] - if len(files) != 1: + resources = importlib.resources.files("dftbplus_step") / "data" + path = resources / "metadata.json" + if not path.exists(): raise RuntimeError("Can't find Slater-Koster metadata.json file") - data = files[0].read_text() + data = path.read_text() self._metadata = json.loads(data) # Data to pass between substeps + self._exe_config = None self._dataset = None # SLAKO dataset used self._subset = None # SLAKO modifier dataset applied to dataset self._reference_energies = None # Reference energies per element. @@ -262,6 +260,12 @@ def git_revision(self): """The git version of this module.""" return dftbplus_step.__git_revision__ + @property + def exe_config(self): + if self._exe_config is None: + self.get_exe_config() + return self._exe_config + def create_parser(self): """Setup the command-line / config file parser""" # parser_name = 'dftbplus-step' @@ -476,3 +480,48 @@ def analyze(self, indent="", data=None, **kwargs): node.analyze(data=data) node = node.next() + + def get_exe_config(self): + """Read the `dftbplus.ini` file, creating if necessary.""" + executor = self.flowchart.executor + + # Read configuration file for DFTB+ if it exists + executor_type = executor.name + full_config = configparser.ConfigParser() + ini_dir = Path(self.global_options["root"]).expanduser() + path = ini_dir / "dftbplus.ini" + + if path.exists(): + full_config.read(ini_dir / "dftbplus.ini") + + # If the section we need doesn't exists, get the default + if not path.exists() or executor_type not in full_config: + resources = importlib.resources.files("dftbplus_step") / "data" + ini_text = (resources / "dftbplus.ini").read_text() + full_config.read_string(ini_text) + + # Getting desperate! Look for an executable in the path + if executor_type not in full_config: + path = shutil.which("dftbplus") + if path is None: + raise RuntimeError( + f"No section for '{executor_type}' in DFTB+ ini file " + f"({ini_dir / 'dftbplus.ini'}), nor in the defaults, nor " + "in the path!" + ) + else: + full_config[executor_type] = { + "installation": "local", + "code": str(path), + } + + # If the ini file does not exist, write it out! + if not path.exists(): + with path.open("w") as fd: + full_config.write(fd) + printer.normal(f"Wrote the DFTB+ configuration file to {path}") + printer.normal("") + + self._exe_config = dict(full_config.items(executor_type)) + # Use the matching version of the seamm-dftbplus image by default. + self._exe_config["version"] = self.version diff --git a/dftbplus_step/dos.py b/dftbplus_step/dos.py index cb2ffa8..52d47c9 100644 --- a/dftbplus_step/dos.py +++ b/dftbplus_step/dos.py @@ -167,7 +167,7 @@ def get_input(self): if "Analysis" not in energy_in: energy_in["Analysis"] = {} analysis = energy_in["Analysis"] - analysis["CalculateForces"] = "No" + analysis["PrintForces"] = "No" elements = set(configuration.atoms.symbols) elements = sorted([*elements]) diff --git a/dftbplus_step/energy.py b/dftbplus_step/energy.py index 09cad5f..cad2ca8 100644 --- a/dftbplus_step/energy.py +++ b/dftbplus_step/energy.py @@ -209,7 +209,7 @@ def get_input(self): # template result = { "Analysis": { - "CalculateForces": "Yes", + "PrintForces": "Yes", }, "Hamiltonian": {"DFTB": {}}, } @@ -217,7 +217,7 @@ def get_input(self): else: result = { "Analysis": { - "CalculateForces": "Yes", + "PrintForces": "Yes", }, "Hamiltonian": {"xTB": {}}, } diff --git a/dftbplus_step/installer.py b/dftbplus_step/installer.py index 3d95e61..e3b906c 100644 --- a/dftbplus_step/installer.py +++ b/dftbplus_step/installer.py @@ -27,7 +27,7 @@ class Installer(seamm_installer.InstallerBase): The Python package `dftbplus-step` should already be installed, using `pip`, `conda`, or similar. This plug-in-specific installer then checks for the Dftbplus executable, installing it if needed, and registers its - location in seamm.ini. + location in ~/SEAMM/dftbplus.ini. There are a number of ways to determine which are the correct Dftbplus executables to use. The aim of this installer is to help the user locate @@ -35,10 +35,10 @@ class Installer(seamm_installer.InstallerBase): 1. The correct executables are already available. - 1. If they are already registered in `seamm.ini` there is nothing else + 1. If they are already registered in `dftbplus.ini` there is nothing else to do. 2. They may be in the current path, in which case they need to be added - to `seamm.ini`. + to `dftbplus.ini`. 3. If a module system is in use, a module may need to be loaded to give access to Dftbplus. 4. They cannot be found automatically, so the user needs to locate the @@ -51,7 +51,8 @@ class Installer(seamm_installer.InstallerBase): default. The Slater-Koster potentials also need to be installed if not present. They are - placed in ~/SEAMM/Parameters/slako by default. + placed in ~/SEAMM/Parameters/slako or, in containers, ~/Parameters/slako by default. + The code uses those in ~/Parameters before those in ~/SEAMM/Parameters. """ def __init__(self, logger=logger): @@ -62,23 +63,14 @@ def __init__(self, logger=logger): logger.debug("Initializing the Dftbplus installer object.") self.section = "dftbplus-step" - self.path_name = "dftbplus-path" self.executables = ["dftb+"] self.resource_path = Path(pkg_resources.resource_filename(__name__, "data/")) self.slako_url = "https://dftb.org/fileadmin/DFTB/public/slako-unpacked.tar.xz" - # What Conda environment is the default? - data = self.configuration.get_values(self.section) - if "conda-environment" in data and data["conda-environment"] != "": - self.environment = data["conda-environment"] - else: - self.environment = "seamm-dftbplus" - # The environment.yaml file for Conda installations. - path = Path(pkg_resources.resource_filename(__name__, "data/")) - logger.debug(f"data directory: {path}") - self.environment_file = path / "seamm-dftbplus.yml" + logger.debug(f"data directory: {self.resource_path}") + self.environment_file = self.resource_path / "seamm-dftbplus.yml" def check(self): """Check the installation and fix errors if requested. @@ -110,11 +102,30 @@ def check(self): True if everything is OK, False otherwise. If `yes` is given as an option, the return value is after fixing the configuration. """ + print("Checking the DFTB+ installation.") + + # What Conda environment is the default? + path = self.configuration.path.parent / "dftbplus.ini" + if not path.exists(): + text = (self.resource_path / "dftbplus.ini").read_text() + path.write_text(text) + print(f" The dftbplus.ini file did not exist. Created {path}") + + self.exe_config.path = path + + # Get the current values + data = self.exe_config.get_values("local") + + if "conda-environment" in data and data["conda-environment"] != "": + self.environment = data["conda-environment"] + else: + self.environment = "seamm-dftbplus" + # Check the DFTB+ executable result = super().check() # And the Slater-Koster parameter files. - self.logger.debug("Checking the Slater-Koster parameters.") + print(" Checking the Slater-Koster parameters.") # First read in the configuration file in the normal fashion # to get the root directory (~/SEAMM usually), which may be needed. @@ -124,7 +135,7 @@ def check(self): root = Path(options["root"]).expanduser().resolve() # Get the values from the configuration - data = self.configuration.get_values(self.section) + data = self.exe_config.get_values("local") if "slako-dir" in data and data["slako-dir"] != "": tmp = data["slako-dir"].replace("${root:SEAMM}", str(root)) slako_dir = Path(tmp).expanduser().resolve() @@ -165,10 +176,8 @@ def check(self): "this?", default="yes", ): - self.configuration.set_value( - self.section, "slako-dir", str(slako_dir) - ) - self.configuration.save() + self.exe_config.set_value("local", "slako-dir", str(slako_dir)) + self.exe_config.save() if install == "check contents": # How do we do this? Check files, versions, what? @@ -179,9 +188,9 @@ def check(self): ) elif install == "full": self.install_files(slako_dir) - self.configuration.set_value(self.section, "slako-dir", str(slako_dir)) - self.configuration.save() - print("Done!\n") + self.exe_config.set_value("local", "slako-dir", str(slako_dir)) + self.exe_config.save() + print(" Done!\n") else: result = False @@ -281,7 +290,7 @@ def install(self): root = Path(options["root"]).expanduser().resolve() # Get the values from the configuration - data = self.configuration.get_values(self.section) + data = self.exe_config.get_values("local") if "slako-dir" in data and data["slako-dir"] != "": tmp = data["slako-dir"].replace("${root:SEAMM}", str(root)) slako_dir = Path(tmp).expanduser().resolve() @@ -292,8 +301,8 @@ def install(self): slako_dir.parent.mkdir(parents=True, exist_ok=True) self.install_files(slako_dir) - self.configuration.set_value(self.section, "slako-dir", str(slako_dir)) - self.configuration.save() + self.exe_config.set_value("local", "slako-dir", str(slako_dir)) + self.exe_config.save() print("Done!\n") @@ -324,7 +333,7 @@ def uninstall(self): self.logger.debug("Uninstalling the Slater-Koster parameters.") # Get the values from the configuration - data = self.configuration.get_values(self.section) + data = self.exe_config.get_values("local") if "slako-dir" in data and data["slako-dir"] != "": # First read in the configuration file in the normal fashion # to get the root directory (~/SEAMM usually), which may be needed. @@ -341,8 +350,8 @@ def uninstall(self): shutil.rmtree(slako_dir, ignore_errors=True) - self.configuration.set_value(self.section, "slako-dir", "") - self.configuration.save() + self.exe_config.set_value("local", "slako-dir", "") + self.exe_config.save() print("Done!\n") else: print("The Slater-Koster files were not installed, so nothing to do.") @@ -373,7 +382,7 @@ def update(self): self.logger.debug("Updating the Slater-Koster parameters.") # Get the values from the configuration - data = self.configuration.get_values(self.section) + data = self.exe_config.get_values("local") if "slako-dir" in data and data["slako-dir"] != "": # First read in the configuration file in the normal fashion # to get the root directory (~/SEAMM usually), which may be needed. @@ -391,8 +400,8 @@ def update(self): "\nwhere the configuration file indicates they should be." "\nFixing the configuration file." ) - self.configuration.set_value(self.section, "slako-dir", "") - self.configuration.save() + self.exe_config.set_value("local", "slako-dir", "") + self.exe_config.save() else: print(f"Updating the Slater-Koster files in {slako_dir}.") slako_dir.parent.mkdir(parents=True, exist_ok=True) From b47a99e8e13ee4d4aea96cec19838b3d85d62f2d Mon Sep 17 00:00:00 2001 From: Paul Saxe Date: Wed, 24 Apr 2024 17:45:53 -0400 Subject: [PATCH 2/2] Fixed incompatibility in versioneer... --- versioneer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/versioneer.py b/versioneer.py index 64fea1c..3aa5da3 100644 --- a/versioneer.py +++ b/versioneer.py @@ -339,9 +339,9 @@ def get_config_from_root(root): # configparser.NoOptionError (if it lacks "VCS="). See the docstring at # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") - parser = configparser.SafeConfigParser() + parser = configparser.ConfigParser() with open(setup_cfg, "r") as f: - parser.readfp(f) + parser.read_file(f) VCS = parser.get("versioneer", "VCS") # mandatory def get(parser, name):