From 3a876cde9aa6d4ff70c13fd43bec0ffff03a006e Mon Sep 17 00:00:00 2001 From: jonasleitner Date: Thu, 20 Aug 2026 11:18:28 +0200 Subject: [PATCH 1/3] basic ruff setup --- .github/workflows/dev_ci.yml | 52 --------------------------------- .github/workflows/master_ci.yml | 11 +++---- .pre-commit-config.yaml | 9 ++++++ pyproject.toml | 8 +++-- requirements.txt | 3 -- 5 files changed, 18 insertions(+), 65 deletions(-) delete mode 100644 .github/workflows/dev_ci.yml create mode 100644 .pre-commit-config.yaml delete mode 100644 requirements.txt diff --git a/.github/workflows/dev_ci.yml b/.github/workflows/dev_ci.yml deleted file mode 100644 index ce46133..0000000 --- a/.github/workflows/dev_ci.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Python package - -on: - push: - branches: ["dev"] - pull_request: - branches: ["dev"] - -jobs: - unit-and-integration: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.12"] - - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt - else pip install -e .; fi - - name: Lint with flake8 - run: | - flake8 - - name: Test (fast) - run: | - pytest -m "not slow" --tb=short - - pyscf-end-to-end: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.12 - uses: actions/setup-python@v3 - with: - python-version: "3.12" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt - else pip install -e .; fi - pip install pyscf pytest - - name: Test (pyscf end-to-end) - run: | - pytest -m slow --tb=short tests/integration/test_full_pipeline_pyscf.py diff --git a/.github/workflows/master_ci.yml b/.github/workflows/master_ci.yml index e7cf431..954bc94 100644 --- a/.github/workflows/master_ci.yml +++ b/.github/workflows/master_ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 @@ -27,12 +27,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt - else pip install -e .; fi - - name: Lint with flake8 - run: | - flake8 + python -m pip install . --group dev + - name: Lint with ruff + uses: pre-commit/action@v3.0.1 - name: Test with pytest run: | pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f87d899 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +# update the pinned versions by running: +# pre-commit autoupdate +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.3 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/pyproject.toml b/pyproject.toml index e88c335..4c5165d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "molbench" version = "0.1.0" description = "This package is a molecular benchmarking package. It allows for interactive comparison of generated data with benchmarks kept inside this package and for subsequent statistical analysis." -authors = ["Linus Bjarne Dittmer"] +authors = ["Linus Bjarne Dittmer, Jonas Leitner"] license = "MIT License" readme = "README.md" @@ -10,8 +10,10 @@ readme = "README.md" python = "^3.12" numpy = "^1.26.2" -[tool.poetry.group.dev.dependencies] -pytest = "^7.4.3" +# PEP 735 group: "pip install --group dev" (pip >= 25.1) or +# "poetry install --with dev" (poetry >= 2.1) +[dependency-groups] +dev = ["pytest>=7.4.3", "pre-commit>=4.0"] [build-system] requires = ["poetry-core"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 18310dc..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -numpy -matplotlib -argparse From c51821c8a7fc11c111a4d5f787688958824bb3db Mon Sep 17 00:00:00 2001 From: jonasleitner Date: Thu, 20 Aug 2026 11:40:35 +0200 Subject: [PATCH 2/3] bump python version --- .github/workflows/master_ci.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/master_ci.yml b/.github/workflows/master_ci.yml index 954bc94..0777895 100644 --- a/.github/workflows/master_ci.yml +++ b/.github/workflows/master_ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12", "3.13", "3.14"] + python-version: ["3.14"] steps: - uses: actions/checkout@v3 diff --git a/pyproject.toml b/pyproject.toml index 4c5165d..80786ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT License" readme = "README.md" [tool.poetry.dependencies] -python = "^3.12" +python = "^3.14" numpy = "^1.26.2" # PEP 735 group: "pip install --group dev" (pip >= 25.1) or From 4fe25a7b5d0a0c343ac597abe70bb322e6602fb8 Mon Sep 17 00:00:00 2001 From: jonasleitner Date: Thu, 20 Aug 2026 12:04:01 +0200 Subject: [PATCH 3/3] format all the code --- README.md | 21 +- molbench/__init__.py | 39 ++- molbench/assignment.py | 46 +-- molbench/bash_wrapper.py | 35 +-- molbench/benchmark_parser.py | 42 +-- molbench/comparison.py | 48 +-- molbench/configuration.py | 22 +- molbench/export.py | 138 +++++---- molbench/external_parser.py | 79 ++--- molbench/formatting.py | 81 +++-- molbench/functions.py | 154 +++++----- molbench/input_constructor.py | 277 +++++++++++------- molbench/json_encoder.py | 8 +- molbench/logger.py | 21 +- molbench/molecule.py | 276 ++++++++++------- molbench/statistics.py | 168 +++++++---- molbench/tree.py | 31 +- tests/conftest.py | 100 +++++-- .../test_benchmark_to_comparison.py | 17 +- tests/integration/test_compare_evaluate.py | 45 ++- tests/integration/test_full_pipeline_pyscf.py | 79 ++--- .../test_input_generation_pipeline.py | 32 +- tests/unit/test_assignment.py | 4 +- tests/unit/test_bash_wrapper.py | 72 +++-- tests/unit/test_benchmark_parser.py | 11 +- tests/unit/test_comparison.py | 47 +-- tests/unit/test_configuration.py | 2 + tests/unit/test_datapoint.py | 1 + tests/unit/test_export.py | 176 ++++++++--- tests/unit/test_external_parser.py | 70 +++-- tests/unit/test_formatting.py | 7 +- tests/unit/test_functions.py | 20 +- tests/unit/test_input_constructor.py | 141 ++++++--- tests/unit/test_json_encoder.py | 30 +- tests/unit/test_molecule.py | 63 ++-- tests/unit/test_molecule_list.py | 176 +++++++---- tests/unit/test_statistics.py | 112 ++++--- tests/unit/test_tree.py | 11 +- 38 files changed, 1715 insertions(+), 987 deletions(-) diff --git a/README.md b/README.md index 8df4d9d..0b2a8e9 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ default name hint. import json from molbench import ExternalParser + def my_parser(filepath): raw = json.load(open(filepath)) name = raw["name"] @@ -121,18 +122,17 @@ def my_parser(filepath): "gs": { "basis": raw["basis"], "method": raw["method"], - "data": { - "energy": {"value": raw["energy"], "unit": "au"} - }, + "data": {"energy": {"value": raw["energy"], "unit": "au"}}, } } return name, system_data, state_data + computed = ExternalParser().load( "/path/to/output/dir", parser=my_parser, - out_suffix=".out", # scan for files with this extension - assignment_suffix=".ass", # companion assignment files (optional) + out_suffix=".out", # scan for files with this extension + assignment_suffix=".ass", # companion assignment files (optional) ) ``` @@ -144,7 +144,7 @@ For excitation spectra benchmarks you will typically also need ```python from molbench import Comparison -c = Comparison() # default separators: ("basis", "method") +c = Comparison() # default separators: ("basis", "method") c.add(bench) c.add(computed) @@ -179,8 +179,8 @@ errors_rel = stats.compare( interest={"method": "my_method"}, reference={"method": "TBE"}, relative=True, - relative_damping=0.01, # adds 0.01 to |ref| in denominator - error_thresh=0.5, # warn if |error| > threshold + relative_damping=0.01, # adds 0.01 to |ref| in denominator + error_thresh=0.5, # warn if |error| > threshold ) ``` @@ -306,8 +306,7 @@ for post-processing. from molbench import CompressedTemplateConstructor tc = CompressedTemplateConstructor("pyscf_ordmp2") -tc.create_inputs(bench, "/path/to/inputs", calc, - reference_path="references.json") +tc.create_inputs(bench, "/path/to/inputs", calc, reference_path="references.json") ``` --- @@ -416,6 +415,7 @@ from molbench import Statistics, register_as_error_measure from molbench.statistics import _collect_errors import numpy as np + @register_as_error_measure def winsorized_mae(signed_errors, assign): errors = _collect_errors(signed_errors, assign) @@ -424,6 +424,7 @@ def winsorized_mae(signed_errors, assign): arr = np.clip(np.abs(errors), None, np.percentile(np.abs(errors), 90)) return float(arr.mean()), len(errors) + result = stats.evaluate(errors, "winsorized_mae", proptype="energy") ``` diff --git a/molbench/__init__.py b/molbench/__init__.py index c53980f..cb2b511 100644 --- a/molbench/__init__.py +++ b/molbench/__init__.py @@ -5,24 +5,37 @@ TemplateConstructor, and LatexExporter for the package's API. """ -from .configuration import config -from .benchmark_parser import JSONBenchmarkParser -from .input_constructor import (InputConstructor, TemplateConstructor, - CompressedTemplateConstructor) from .bash_wrapper import create_bash_files, make_send_script +from .benchmark_parser import JSONBenchmarkParser from .comparison import Comparison -from .statistics import Statistics, register_as_error_measure +from .configuration import config from .export import Exporter, LatexExporter from .external_parser import ExternalParser -from .molecule import Molecule +from .input_constructor import ( + CompressedTemplateConstructor, + InputConstructor, + TemplateConstructor, +) from .json_encoder import MolbenchJSONEncoder +from .molecule import Molecule +from .statistics import Statistics, register_as_error_measure -__all__ = ["config", "Molecule", "InputConstructor", - "TemplateConstructor", "CompressedTemplateConstructor", - "create_bash_files", "make_send_script", - "JSONBenchmarkParser", - "Comparison", "Statistics", "register_as_error_measure", - "Exporter", "LatexExporter", - "ExternalParser", "MolbenchJSONEncoder"] +__all__ = [ + "Comparison", + "CompressedTemplateConstructor", + "Exporter", + "ExternalParser", + "InputConstructor", + "JSONBenchmarkParser", + "LatexExporter", + "MolbenchJSONEncoder", + "Molecule", + "Statistics", + "TemplateConstructor", + "config", + "create_bash_files", + "make_send_script", + "register_as_error_measure", +] __version__ = "0.0.1" __authors__ = ["Linus Bjarne Dittmer", "Jonas Leitner"] diff --git a/molbench/assignment.py b/molbench/assignment.py index eb7c03b..3f40b34 100644 --- a/molbench/assignment.py +++ b/molbench/assignment.py @@ -1,10 +1,14 @@ +from collections.abc import Callable + from . import logger as log -from typing import Callable -def new_assignment_file(state_ids: list, comment_token: str = "#", - id_separator: str = "==>", - null_token: str = "null") -> str: +def new_assignment_file( + state_ids: list, + comment_token: str = "#", + id_separator: str = "==>", + null_token: str = "null", +) -> str: """ Creates the content of an assignment file. @@ -26,12 +30,14 @@ def new_assignment_file(state_ids: list, comment_token: str = "#", return content -def parse_assignment_file(assignmentfile: str, - comment_token: str = "#", - id_separator: str = "==>", - null_token: str = "null", - import_external: Callable | None = None, - import_ref: Callable | None = None) -> dict: +def parse_assignment_file( + assignmentfile: str, + comment_token: str = "#", + id_separator: str = "==>", + null_token: str = "null", + import_external: Callable | None = None, + import_ref: Callable | None = None, +) -> dict: """ Parses an assignment file. @@ -71,15 +77,16 @@ def parse_assignment_file(assignmentfile: str, # - read both state id's and import them if necessary assignment = assignment.split(id_separator) if len(assignment) != 2: - log.critical(f"Invalid use of state id separator {id_separator} in" - f" line {line} of assignment file {assignmentfile}.", - "Assignment: parse_assignment_file") + log.critical( + f"Invalid use of state id separator {id_separator} in" + f" line {line} of assignment file {assignmentfile}.", + "Assignment: parse_assignment_file", + ) ref = assignment[0].strip() external = assignment[1].strip() if ref == null_token or external == null_token: # skip not assigned - log.warning(f"Unassigned state in file {assignmentfile}.", - "Assigment") + log.warning(f"Unassigned state in file {assignmentfile}.", "Assigment") continue if import_external is not None: @@ -88,8 +95,11 @@ def parse_assignment_file(assignmentfile: str, ref = import_ref(ref) if external in state_assignments: - log.warning(f"The external state {external} in assignment file " - f"{assignmentfile} is assigned twice. Overwriting the " - "first assignment.", "Assigment") + log.warning( + f"The external state {external} in assignment file " + f"{assignmentfile} is assigned twice. Overwriting the " + "first assignment.", + "Assigment", + ) state_assignments[external] = ref return state_assignments diff --git a/molbench/bash_wrapper.py b/molbench/bash_wrapper.py index 6fc672b..ec9ea06 100644 --- a/molbench/bash_wrapper.py +++ b/molbench/bash_wrapper.py @@ -1,10 +1,11 @@ -import os import glob +import os import subprocess import typing + from . import logger as log +from .configuration import config from .functions import substitute_template -from . import config def create_bash_files(files: list, command: str) -> list: @@ -34,28 +35,28 @@ def create_bash_files(files: list, command: str) -> list: try: infilename = os.path.basename(f) cmd = command.strip() + " " + infilename - log.info(f"Now building script for {infilename}: {f}", - "Bash Wrapper") - result = subprocess.run(cmd, shell=True) + log.info(f"Now building script for {infilename}: {f}", "Bash Wrapper") + result = subprocess.run(cmd, shell=True, check=False) if getattr(result, "returncode", 0) != 0: - log.error(f"Command failed for {infilename} (exit code " - f"{result.returncode}): {cmd}", "Bash Wrapper") + log.error( + f"Command failed for {infilename} (exit code " + f"{result.returncode}): {cmd}", + "Bash Wrapper", + ) log.debug(f"Executing command : {cmd}", "Bash Wrapper") fname_no_ext = os.path.splitext(infilename)[0] all_shs = glob.glob("*.sh") all_shs.extend(glob.glob("*.sbatch")) - local_execs = [os.path.abspath(sh) for sh in all_shs - if fname_no_ext in sh] + local_execs = [os.path.abspath(sh) for sh in all_shs if fname_no_ext in sh] bash_files.extend(local_execs) finally: os.chdir(basepath) return bash_files -def make_send_script(bashfiles: list, send_command: str, - sendscript: typing.IO): +def make_send_script(bashfiles: list, send_command: str, sendscript: typing.IO): """ Generate a script for sending all jobscripts to a cluster. @@ -72,11 +73,11 @@ def make_send_script(bashfiles: list, send_command: str, sendscript_content = ( "#!/bin/bash\n" "function cd_and_sbatch() {\n" - " local script_file=\"$1\"\n" - " local folder=\"$2\"\n" - " echo \"Sending $script_file\"\n" - " cd \"$folder\"\n" - f" {send_command.strip()} \"$script_file\"\n" + ' local script_file="$1"\n' + ' local folder="$2"\n' + ' echo "Sending $script_file"\n' + ' cd "$folder"\n' + f' {send_command.strip()} "$script_file"\n' "}\n\n" ) @@ -84,7 +85,7 @@ def make_send_script(bashfiles: list, send_command: str, fpath = os.path.abspath(os.path.dirname(f)) infilename = os.path.basename(f) - addendum = f"cd_and_sbatch \"{infilename}\" \"{fpath}\"\n" + addendum = f'cd_and_sbatch "{infilename}" "{fpath}"\n' sendscript_content += addendum sendscript.write(sendscript_content) diff --git a/molbench/benchmark_parser.py b/molbench/benchmark_parser.py index f7d0d02..6aca9d3 100644 --- a/molbench/benchmark_parser.py +++ b/molbench/benchmark_parser.py @@ -1,12 +1,10 @@ -"""Python script for handling benchmarks. +"""Python script for handling benchmarks.""" - -""" +import json +import os from . import logger as log from .molecule import Molecule, MoleculeList -import os -import json class BenchmarkParser: @@ -27,24 +25,29 @@ def _collect_premade_benchmarks(cls) -> None: cls.premade_benchmarks = {} for root, _, files in os.walk(rpath): for file in files: - if file.endswith('.json'): + if file.endswith(".json"): key = os.path.splitext(file)[0] val = os.path.abspath(os.path.join(root, file)) cls.premade_benchmarks.update({key: val}) - def load(self, benchmark: str, benchmark_id=None, - use_local_benchmark: bool = False) -> list[Molecule]: + def load( + self, benchmark: str, benchmark_id=None, use_local_benchmark: bool = False + ) -> list[Molecule]: if not use_local_benchmark and benchmark in self.premade_benchmarks: benchfile = self.premade_benchmarks[benchmark] else: if not os.path.exists(benchmark): - log.critical(f"Benchmark file {benchmark} does not exists or " - "cannot be seen.", "Benchmark Parser") + log.critical( + f"Benchmark file {benchmark} does not exists or cannot be seen.", + "Benchmark Parser", + ) benchfile = benchmark content = self.parse_benchmark(benchfile) if not content: - log.critical(f"Benchmark file {benchmark} was found but could not" - " be loaded.", "Benchmark Parser") + log.critical( + f"Benchmark file {benchmark} was found but could not be loaded.", + "Benchmark Parser", + ) if benchmark_id is None: benchmark_id = benchmark return MoleculeList( @@ -53,10 +56,12 @@ def load(self, benchmark: str, benchmark_id=None, ) def parse_benchmark(self, benchmarkfile: str) -> dict | None: - raise NotImplementedError("The parse_benchmark function is not " - "implemented in the BenchmarkParser " - "superclass. Please use a child class " - "instead.") + raise NotImplementedError( + "The parse_benchmark function is not " + "implemented in the BenchmarkParser " + "superclass. Please use a child class " + "instead." + ) class JSONBenchmarkParser(BenchmarkParser): @@ -77,5 +82,6 @@ def parse_benchmark(self, benchmarkfile: str) -> dict | None: try: return json.load(open(benchmarkfile, "r")) except json.JSONDecodeError: - log.critical(f"Could not read benchmark file {benchmarkfile}.", - "Benchmark Parser") + log.critical( + f"Could not read benchmark file {benchmarkfile}.", "Benchmark Parser" + ) diff --git a/molbench/comparison.py b/molbench/comparison.py index 06310fd..61c940d 100644 --- a/molbench/comparison.py +++ b/molbench/comparison.py @@ -8,10 +8,11 @@ """ +import numpy + from . import logger as log -from .molecule import Molecule, MoleculeList, Datapoint from .functions import walk_dict_by_key, walk_dict_values -import numpy +from .molecule import Datapoint, Molecule, MoleculeList class Comparison(dict): @@ -39,7 +40,8 @@ def __init__(self, *data_separators: str) -> None: if data_separators: # remove the special separators (used anyway) data_separators = tuple( - separator for separator in data_separators + separator + for separator in data_separators if separator not in ["name", "proptype", "data_id"] ) self._data_separators = data_separators @@ -89,13 +91,13 @@ def _import_value(self, value): if isinstance(value, (int, float, complex, str)): return value elif isinstance(value, dict): - if ((len(value.keys()) == 2) - and ("value" in value) - and ("unit" in value)): + if (len(value.keys()) == 2) and ("value" in value) and ("unit" in value): return Datapoint(value["value"], value["unit"]) - log.error(f"Could not interpret {value} as a datapoint. Expected " - "a dict with exactly the keys 'value' and 'unit'.", - "Comparison._import_value") + log.error( + f"Could not interpret {value} as a datapoint. Expected " + "a dict with exactly the keys 'value' and 'unit'.", + "Comparison._import_value", + ) return None elif isinstance(value, Datapoint): return value @@ -111,8 +113,9 @@ def add(self, dataset: MoleculeList) -> None: def add_molecule(self, data: Molecule) -> None: if not isinstance(data, Molecule): - log.error(f"Can't add data of type {type(data)}.", - "Comparison.add_molecule") + log.error( + f"Can't add data of type {type(data)}.", "Comparison.add_molecule" + ) return # We define a wrapper around prop.get to filter out transition ids @@ -124,12 +127,15 @@ def _propget(prop, key, default=None): for prop in data.state_data.values(): separators = [_propget(prop, key) for key in self.data_separators] - proptypes = list(prop.get("data", dict()).keys()) - values = [prop.get("data", dict())[k] for k in proptypes] + proptypes = list(prop.get("data", {}).keys()) + values = [prop.get("data", {})[k] for k in proptypes] for proptype, value in zip(proptypes, values): - if proptype is None or value is None or any(v is None - for v in separators): + if ( + proptype is None + or value is None + or any(v is None for v in separators) + ): continue # move into and establish the nested dict structure if data.name not in self: # special separator: name @@ -147,15 +153,17 @@ def _propget(prop, key, default=None): propci = prop["component index"] data_id += f"_{propci}" if data_id in d: - log.warning(f"data_id {data.data_id} is not unique. Found " - f"conflicting entry for {data.name}, {separators} " - f"and {proptype}. Overwriting the exisiting value", - "Comparison.add_molecule") + log.warning( + f"data_id {data.data_id} is not unique. Found " + f"conflicting entry for {data.name}, {separators} " + f"and {proptype}. Overwriting the exisiting value", + "Comparison.add_molecule", + ) d[data_id] = self._import_value(value) def walk_by_key(self, desired_key): """Walk the dictionary looking for the desired key, returning - the sqeuence of keys and the corresponding value.""" + the sqeuence of keys and the corresponding value.""" return walk_dict_by_key(self, desired_key=desired_key) def walk_values(self): diff --git a/molbench/configuration.py b/molbench/configuration.py index d480184..92a541c 100644 --- a/molbench/configuration.py +++ b/molbench/configuration.py @@ -1,9 +1,9 @@ -"""Configuration main class. +"""Configuration main class.""" -""" - -import os import json +import os +from typing import ClassVar + import molbench.logger as log @@ -25,11 +25,7 @@ class Configuration(dict): """ - required_fields = { - "threads": 1, - "memory": 50000, - "walltime": "12:00:00" - } + required_fields: ClassVar = {"threads": 1, "memory": 50000, "walltime": "12:00:00"} def __init__(self, *args, **kwargs): """ @@ -59,9 +55,11 @@ def load_from_file(self): try: with open(config_path, "r") as f: self.update(json.load(f)) - except Exception: - log.critical(f"Configuration file at {config_path} could not be " - "parsed.", "Configuration") + except Exception: # noqa: BLE001 + log.critical( + f"Configuration file at {config_path} could not be parsed.", + "Configuration", + ) def __setattr__(self, attr: str, val) -> None: """ diff --git a/molbench/export.py b/molbench/export.py index e3dc9a9..464f58d 100644 --- a/molbench/export.py +++ b/molbench/export.py @@ -1,9 +1,10 @@ -from .comparison import Comparison -from .formatting import LatexFormatter, Formatter -from .tree import Node, DummyNode -from . import logger as log -from itertools import chain import typing +from itertools import chain + +from . import logger as log +from .comparison import Comparison +from .formatting import Formatter, LatexFormatter +from .tree import DummyNode, Node class Exporter: @@ -26,35 +27,48 @@ class Exporter: """ def export(self, *args, **kwargs) -> str | None: - raise NotImplementedError("Export function has to be implemented on " - "the child classes.") + raise NotImplementedError( + "Export function has to be implemented on the child classes." + ) _REQUIRED_FORMATTER_METHODS = ( - "join_labels", "init_table", "finalize_table", "table_header", - "table_content", "multicolumn", "multirow", + "join_labels", + "init_table", + "finalize_table", + "table_header", + "table_content", + "multicolumn", + "multirow", ) class TableExporter(Exporter): - def __init__(self, formatter: Formatter, sort_cols: bool = True, - sort_rows: bool = True, sparse_row_labels: bool = True, - multirow: bool = False): - missing = [m for m in _REQUIRED_FORMATTER_METHODS - if not hasattr(formatter, m)] + def __init__( + self, + formatter: Formatter, + sort_cols: bool = True, + sort_rows: bool = True, + sparse_row_labels: bool = True, + multirow: bool = False, + ): + missing = [m for m in _REQUIRED_FORMATTER_METHODS if not hasattr(formatter, m)] if missing: log.critical( f"{type(formatter).__name__} is missing the table-structure " f"method(s) {missing} required by TableExporter (e.g. use " - "LatexFormatter instead of StdFormatter).", "TableExporter") + "LatexFormatter instead of StdFormatter).", + "TableExporter", + ) self.formatter = formatter self.sort_cols = sort_cols self.sort_rows = sort_rows self.sparse_row_labels = sparse_row_labels self.multirow = multirow - def export(self, data: Comparison, property, outfile: typing.IO, - columns, rows=None) -> str | None: + def export( + self, data: Comparison, property, outfile: typing.IO, columns, rows=None + ) -> str | None: # no row labels provided -> use all available keys if rows is None: rows = DummyNode() @@ -68,8 +82,8 @@ def export(self, data: Comparison, property, outfile: typing.IO, # prepare the data for the export by constructing row and column labels # for all data points - prepared_data, col_label_tree, row_label_tree = ( - self._prepare_data(data, columns, rows, property) + prepared_data, col_label_tree, row_label_tree = self._prepare_data( + data, columns, rows, property ) # sort the column and row trees according to the labels if self.sort_cols: @@ -88,31 +102,32 @@ def export(self, data: Comparison, property, outfile: typing.IO, for generation in rows.traverse_generations() ) # init the table - preamble = self.formatter.init_table(len(additional_col_labels), - len(column_labels)) + preamble = self.formatter.init_table( + len(additional_col_labels), len(column_labels) + ) # Build the header of the table (column labels) - header = self._prepare_table_header(col_label_tree, - additional_col_labels) + header = self._prepare_table_header(col_label_tree, additional_col_labels) header = self.formatter.table_header(header) # Fill the body of the table - content = self._prepare_content(prepared_data, row_label_tree, - column_labels) + content = self._prepare_content(prepared_data, row_label_tree, column_labels) content = self.formatter.table_content(content) # finalize the table (close environments etc.) finish = self.formatter.finalize_table() - table = "\n".join((preamble, header, content, finish)) + table = f"{preamble}\n{header}\n{content}\n{finish}" outfile.write(table) - def _prepare_data(self, comparison: Comparison, column_tree: Node, - row_tree: Node, prop): + def _prepare_data( + self, comparison: Comparison, column_tree: Node, row_tree: Node, prop + ): # Ensure that all nodes are present in comparison! data_structure = comparison.structure column_nodes = tuple(column_tree.traverse_generations()) row_nodes = tuple(row_tree.traverse_generations()) - if any(n.value not in data_structure for n in - chain.from_iterable(chain(column_nodes, row_nodes))): - log.critical("A key is not available in the provided Comparison.", - "Export") + if any( + n.value not in data_structure + for n in chain.from_iterable(chain(column_nodes, row_nodes)) + ): + log.critical("A key is not available in the provided Comparison.", "Export") col_node_cache = {"root": DummyNode()} row_node_cache = {"root": DummyNode()} @@ -134,15 +149,17 @@ def _prepare_data(self, comparison: Comparison, column_tree: Node, f"{row_l}, column={column_l}). This usually means " "the row/column trees don't cover all separators in " "the Comparison - the values will be joined into " - "one cell.", "Export") + "one cell.", + "Export", + ) data[row_l][column_l].append(value) col_label_tree = col_node_cache["root"] row_label_tree = row_node_cache["root"] return data, col_label_tree, row_label_tree - def _build_row_column_label(self, columns: tuple, rows: tuple, - sep_names: tuple, - sep_vals: tuple) -> tuple[tuple, tuple]: + def _build_row_column_label( + self, columns: tuple, rows: tuple, sep_names: tuple, sep_vals: tuple + ) -> tuple[tuple, tuple]: # construct the row and column label for a given entry row_l, column_l = {}, {} for name, val in zip(sep_names, sep_vals): @@ -174,22 +191,17 @@ def _build_row_column_label(self, columns: tuple, rows: tuple, # sort the labels for each generation to maintain the order # as given in the input tree for gen, labels in column_l.items(): - column_l[gen] = self.formatter.join_labels( - v for _, v in sorted(labels) - ) + column_l[gen] = self.formatter.join_labels(v for _, v in sorted(labels)) for gen, labels in row_l.items(): - row_l[gen] = self.formatter.join_labels( - v for _, v in sorted(labels) - ) + row_l[gen] = self.formatter.join_labels(v for _, v in sorted(labels)) # finally sort the labels such that the upper entries of the tree # appear first return ( tuple(v for _, v in sorted(column_l.items())), - tuple(v for _, v in sorted(row_l.items())) + tuple(v for _, v in sorted(row_l.items())), ) - def _add_to_label_tree(self, label: tuple[str, ...], - node_cache: dict) -> None: + def _add_to_label_tree(self, label: tuple[str, ...], node_cache: dict) -> None: # Helper function for building the label trees # constructs all nodes for a given entry and inserts them in the tree parent = node_cache["root"] @@ -201,8 +213,9 @@ def _add_to_label_tree(self, label: tuple[str, ...], node_cache[key] = node parent = node - def _prepare_table_header(self, col_label_tree: Node, - additional_cols: tuple[str, ...]) -> list: + def _prepare_table_header( + self, col_label_tree: Node, additional_cols: tuple[str, ...] + ) -> list: # Build the table header as nested list of strings rows = [] prefix = tuple("" for _ in range(len(additional_cols))) @@ -220,8 +233,9 @@ def _prepare_table_header(self, col_label_tree: Node, rows.append(row) return rows - def _prepare_content(self, data: dict, row_label_tree: Node, - column_labels: tuple[str, ...]) -> list: + def _prepare_content( + self, data: dict, row_label_tree: Node, column_labels: tuple[str, ...] + ) -> list: # Build the content of the table as nested list content: list[list[str]] = [] prev_row_label = None @@ -233,8 +247,9 @@ def _prepare_content(self, data: dict, row_label_tree: Node, if prev_row_label is None: write_label = [True for _ in range(len(row_label))] else: - write_label = [label != prev for label, prev in - zip(row_label, prev_row_label)] + write_label = [ + label != prev for label, prev in zip(row_label, prev_row_label) + ] for label, write, node in zip(row_label, write_label, tree_path): # possibly skip repeating row labels if self.sparse_row_labels and not write: @@ -256,11 +271,20 @@ def _prepare_content(self, data: dict, row_label_tree: Node, class LatexExporter(TableExporter): - def __init__(self, formatter: Formatter = None, sort_cols: bool = True, - sort_rows: bool = True, sparse_row_labels: bool = True, - multirow: bool = False): + def __init__( + self, + formatter: Formatter = None, + sort_cols: bool = True, + sort_rows: bool = True, + sparse_row_labels: bool = True, + multirow: bool = False, + ): if formatter is None: formatter = LatexFormatter() - super().__init__(formatter, sort_cols=sort_cols, sort_rows=sort_rows, - sparse_row_labels=sparse_row_labels, - multirow=multirow) + super().__init__( + formatter, + sort_cols=sort_cols, + sort_rows=sort_rows, + sparse_row_labels=sparse_row_labels, + multirow=multirow, + ) diff --git a/molbench/external_parser.py b/molbench/external_parser.py index 3bbe5bd..f588a26 100644 --- a/molbench/external_parser.py +++ b/molbench/external_parser.py @@ -1,22 +1,24 @@ -"""Parsing class for external data -""" +"""Parsing class for external data""" -from . import logger as log -from .molecule import Molecule, MoleculeList -from .assignment import parse_assignment_file -import os.path import inspect +import os.path +from collections.abc import Callable from pathlib import Path from typing import Any -from collections.abc import Callable +from . import logger as log +from .assignment import parse_assignment_file +from .molecule import Molecule, MoleculeList -class ExternalParser: - def load(self, filepath: str, - parser: Callable[[str], tuple[str, dict | None, dict | None]], - out_suffix: str = ".out", - assignment_suffix: str = ".ass"): +class ExternalParser: + def load( + self, + filepath: str, + parser: Callable[[str], tuple[str, dict | None, dict | None]], + out_suffix: str = ".out", + assignment_suffix: str = ".ass", + ): """ Loads the external data in the given folder. @@ -39,18 +41,21 @@ def load(self, filepath: str, param_num: int = len(inspect.signature(parser).parameters) if param_num not in (1, 2): - log.critical("Parser Callable can only have the following " - + "arguments:\n\noutput_filepath : str\n " - + "Path to the output file\nname : str (optional)" - + "\n Name for the molecule object\n\nThe given" - + f" Callable has {param_num} parameters.", - "ExternalParser") + log.critical( + "Parser Callable can only have the following " + + "arguments:\n\noutput_filepath : str\n " + + "Path to the output file\nname : str (optional)" + + "\n Name for the molecule object\n\nThe given" + + f" Callable has {param_num} parameters.", + "ExternalParser", + ) data = MoleculeList() for outf in outfiles: # load the file and add assignments if available mol = self._load_file( - outfile=outf, out_parser=parser, + outfile=outf, + out_parser=parser, assignment_parser=parse_assignment_file, assignment_suffix=assignment_suffix, ) @@ -58,9 +63,13 @@ def load(self, filepath: str, return data - def _load_file(self, outfile: str, out_parser: Callable, - assignment_parser: Callable, - assignment_suffix: str = ".ass") -> Molecule: + def _load_file( + self, + outfile: str, + out_parser: Callable, + assignment_parser: Callable, + assignment_suffix: str = ".ass", + ) -> Molecule: """ Parses and imports the given outfile, and if possible finds the corresponding assignment file and adds the assignments of states. @@ -86,34 +95,36 @@ def _load_file(self, outfile: str, out_parser: Callable, else: parsed = out_parser(outfile) if not isinstance(parsed, (tuple, list)) or len(parsed) != 3: - log.critical("Output file parser must return a tuple/list of " - "(name, system_data, state_data), but got " - f"{parsed!r} instead.", "ExternalParser") + log.critical( + "Output file parser must return a tuple/list of " + "(name, system_data, state_data), but got " + f"{parsed!r} instead.", + "ExternalParser", + ) name: str = parsed[0] system_data: dict[str, Any] = parsed[1] state_data: dict[str, Any] = parsed[2] mol = Molecule.from_external(system_data, state_data, outfile, name) - ass_file = self._assignmentfile_from_outfile(outfile, - assignment_suffix) + ass_file = self._assignmentfile_from_outfile(outfile, assignment_suffix) if ass_file is not None: # assignment file exists -> add assignments assignments = assignment_parser(ass_file) mol.add_assignments(assignments) return mol - def _fetch_all_outfiles(self, path: str, - suffix: str = '.out') -> list[str]: + def _fetch_all_outfiles(self, path: str, suffix: str = ".out") -> list[str]: outfiles = [] - for root, _, files in os.walk(os.path.abspath(path), topdown=True, - followlinks=True): + for root, _, files in os.walk( + os.path.abspath(path), topdown=True, followlinks=True + ): for f in files: if f.endswith(suffix): fp = os.path.abspath(os.path.join(root, f)) outfiles.append(fp) return outfiles - def _assignmentfile_from_outfile(self, outfile: str, - assignment_suffix: str = ".ass" - ) -> str | None: + def _assignmentfile_from_outfile( + self, outfile: str, assignment_suffix: str = ".ass" + ) -> str | None: """ Finds the assignmentfile for a given output file. Returns None if no assignment file could be found. diff --git a/molbench/formatting.py b/molbench/formatting.py index 0316347..85c8e04 100644 --- a/molbench/formatting.py +++ b/molbench/formatting.py @@ -2,8 +2,8 @@ class Formatter: - """A base class for formatting datapoints. - """ + """A base class for formatting datapoints.""" + def format_datapoint(self, value: Any) -> str: """Returns a formatted datapoint value @@ -27,9 +27,9 @@ def format_datapoint(self, value: Any) -> str: class StdFormatter(Formatter): - - def __init__(self, n_decimals: int = 5, empty_field: str = "", - value_delimiter: str = ", ") -> None: + def __init__( + self, n_decimals: int = 5, empty_field: str = "", value_delimiter: str = ", " + ) -> None: """The standard formatter for text. Parameters @@ -66,10 +66,8 @@ class attribute "value_delimiter". return str(round(value, self.n_decimals)) elif isinstance(value, str): return value - elif hasattr(value, '__iter__'): # dict, set, list, tuple, ... - return self.value_delimiter.join( - self.format_datapoint(v) for v in value - ) + elif hasattr(value, "__iter__"): # dict, set, list, tuple, ... + return self.value_delimiter.join(self.format_datapoint(v) for v in value) elif value is None: return self.empty_field else: @@ -77,15 +75,19 @@ class attribute "value_delimiter". class LatexFormatter(StdFormatter): - def __init__(self, n_decimals: int = 5, empty_field: str = "", - value_delimiter: str = ", ", - label_delimiter: str = "/", - column_delimiter: str = " & ", - row_delimiter: str = "\\\\ \n", - column_alignment: str = "c", - additional_column_alignment: str = "l", - multicol_alignment: str = "c", - multirow_width: str = "*") -> None: + def __init__( + self, + n_decimals: int = 5, + empty_field: str = "", + value_delimiter: str = ", ", + label_delimiter: str = "/", + column_delimiter: str = " & ", + row_delimiter: str = "\\\\ \n", + column_alignment: str = "c", + additional_column_alignment: str = "l", + multicol_alignment: str = "c", + multirow_width: str = "*", + ) -> None: """Used to create a LaTeX table of all datapoints Parameters @@ -120,9 +122,16 @@ def __init__(self, n_decimals: int = 5, empty_field: str = "", self.multicol_alignment = multicol_alignment self.multirow_width = multirow_width - def init_table(self, n_additional_cols: int, n_columns: int,) -> str: - alignment = (self.additional_column_alignment * n_additional_cols + - "|" + self.column_alignment * n_columns) + def init_table( + self, + n_additional_cols: int, + n_columns: int, + ) -> str: + alignment = ( + self.additional_column_alignment * n_additional_cols + + "|" + + self.column_alignment * n_columns + ) return r"\begin{table}" + "\n" + r"\begin{tabular}{" + alignment + "}" def finalize_table(self): @@ -130,14 +139,12 @@ def finalize_table(self): def table_header(self, labels: tuple[tuple[str, ...], ...]) -> str: return ( - self.join_rows(tuple(self.join_columns(row) for row in labels)) + - r"\\ \hline" + self.join_rows(tuple(self.join_columns(row) for row in labels)) + + r"\\ \hline" ) def table_content(self, content: tuple[tuple[str, ...], ...]) -> str: - return ( - self.join_rows(tuple(self.join_columns(row) for row in content)) - ) + return self.join_rows(tuple(self.join_columns(row) for row in content)) def join_labels(self, labels: tuple[str, ...]) -> str: return self.label_delimiter.join(labels) @@ -149,9 +156,23 @@ def join_rows(self, rows: tuple[str, ...]) -> str: return self.row_delimiter.join(rows) def multicolumn(self, width: int, value: str) -> str: - return (r"\multicolumn{" + str(width) + "}{" + - self.multicol_alignment + "}{" + value + "}") + return ( + r"\multicolumn{" + + str(width) + + "}{" + + self.multicol_alignment + + "}{" + + value + + "}" + ) def multirow(self, heigth: int, value: str) -> str: - return (r"\multirow{" + str(heigth) + "}{" + - self.multirow_width + "}{" + value + "}") + return ( + r"\multirow{" + + str(heigth) + + "}{" + + self.multirow_width + + "}{" + + value + + "}" + ) diff --git a/molbench/functions.py b/molbench/functions.py index b4c5104..4ca1fc1 100644 --- a/molbench/functions.py +++ b/molbench/functions.py @@ -20,8 +20,10 @@ def substitute_template(template: str, subvals: dict) -> tuple[str, ...]: that an error is thrown if the length of charge_list and multiplicity_list are not equal. """ - if not any(key.endswith("_list") and isinstance(val, (list, tuple)) - for key, val in subvals.items()): + if not any( + key.endswith("_list") and isinstance(val, (list, tuple)) + for key, val in subvals.items() + ): return (_substitute_single_template(template, subvals),) # split subvals in values to expand and common values # and remove the _list suffix @@ -35,27 +37,33 @@ def substitute_template(template: str, subvals: dict) -> tuple[str, ...]: # ensure that all expansion lists are of the same length n_variants = len(to_expand[0][1]) if not all(len(val) == n_variants for _, val in to_expand): - log.critical("List subvals to expand into multiple templates have to " - f"be all of the same length. Got\n{to_expand}\n from\n" - f"{subvals}", "Functions: Substitute Template") + log.critical( + "List subvals to expand into multiple templates have to " + f"be all of the same length. Got\n{to_expand}\n from\n" + f"{subvals}", + "Functions: Substitute Template", + ) if n_variants == 0: - log.warning("Received (an) empty list(s) to expand for placeholders " - f"{[key for key, _ in to_expand]} - no template variants " - f"will be generated from\n{subvals}", - "Functions: Substitute Template") + log.warning( + "Received (an) empty list(s) to expand for placeholders " + f"{[key for key, _ in to_expand]} - no template variants " + f"will be generated from\n{subvals}", + "Functions: Substitute Template", + ) # build subvals dicts for all variants variants = [dict(common) for _ in range(n_variants)] for key, val_list in to_expand: for var, val in zip(variants, val_list): if key in var: - log.error(f"The key {key} generated from {key}_list already " - f"exists in the variant\n{var}\nOverwriting existing" - " value", "Functions: Substitute Template", - "KeyError") + log.error( + f"The key {key} generated from {key}_list already " + f"exists in the variant\n{var}\nOverwriting existing" + " value", + "Functions: Substitute Template", + "KeyError", + ) var[key] = val - return tuple( - _substitute_single_template(template, var) for var in variants - ) + return tuple(_substitute_single_template(template, var) for var in variants) def _substitute_single_template(template: str, subvals: dict) -> str: @@ -75,54 +83,64 @@ def _substitute_single_template(template: str, subvals: dict) -> str: # bracket of a later placeholder stop = template.find("]]", start) if stop == -1: - log.error("Found an unclosed placeholder starting at " - f"'{template[start:start+30]}' in the template. " - "Leaving it as literal text - check for a missing " - "closing ']]'.", "Functions: Substitute Template") + log.error( + "Found an unclosed placeholder starting at " + f"'{template[start : start + 30]}' in the template. " + "Leaving it as literal text - check for a missing " + "closing ']]'.", + "Functions: Substitute Template", + ) break - key = template[start+2:stop] + key = template[start + 2 : stop] # check if we have [[key->number]] and get the key and number val_idx = None if "->" in key: key, val_idx = key.split("->") if not val_idx.isnumeric(): - log.critical(f"{val_idx} is not a number. Placeholders of the " - f"form {key}->{val_idx} need to be of the form " - "'key'->'number'.", - "Functions: Substitute Template") + log.critical( + f"{val_idx} is not a number. Placeholders of the " + f"form {key}->{val_idx} need to be of the form " + "'key'->'number'.", + "Functions: Substitute Template", + ) val_idx = int(val_idx) # get the actual value val = subvals.get(key, None) if val_idx is not None and val is not None: if not isinstance(val, (list, tuple)): - log.critical("The value for a placeholder of the form " - "'[[placeholder->number]]' needs to be a 'list' " - f"or 'tuple'. Found {val} for key {key}.", - "Functions: Substitute Template") + log.critical( + "The value for a placeholder of the form " + "'[[placeholder->number]]' needs to be a 'list' " + f"or 'tuple'. Found {val} for key {key}.", + "Functions: Substitute Template", + ) if val_idx >= len(val): - log.critical(f"Index {val_idx} is out of range for " - f"placeholder '{key}' which only has " - f"{len(val)} element(s).", - "Functions: Substitute Template") + log.critical( + f"Index {val_idx} is out of range for " + f"placeholder '{key}' which only has " + f"{len(val)} element(s).", + "Functions: Substitute Template", + ) val = val[val_idx] if val is None: - log.critical(f"No value available for placeholder '{key}'. " - f"Available substitution keys are " - f"{list(subvals.keys())}. If '{key}' doesn't look " - "like a real placeholder name (e.g. it contains " - "spaces or shell syntax), the template likely " - "contains literal '[[' / ']]' content - such as " - "bash's own '[[ ... ]]' test syntax - that " - "collides with the placeholder delimiters and " - "needs to be rewritten to avoid double brackets.", - "Functions: Substitute Template") + log.critical( + f"No value available for placeholder '{key}'. " + f"Available substitution keys are " + f"{list(subvals.keys())}. If '{key}' doesn't look " + "like a real placeholder name (e.g. it contains " + "spaces or shell syntax), the template likely " + "contains literal '[[' / ']]' content - such as " + "bash's own '[[ ... ]]' test syntax - that " + "collides with the placeholder delimiters and " + "needs to be rewritten to avoid double brackets.", + "Functions: Substitute Template", + ) # update the template - template = template.replace(template[start:stop+2], str(val)) + template = template.replace(template[start : stop + 2], str(val)) return template -def default_name_template(file_expansion_keys: tuple, - file_extension: str) -> str: +def default_name_template(file_expansion_keys: tuple, file_extension: str) -> str: # construct a name that ensures that each file has a unique name name_template = "[[name]]_[[method]]" for key in file_expansion_keys: @@ -131,7 +149,7 @@ def default_name_template(file_expansion_keys: tuple, return name_template + file_extension -def walk_dict_by_key(indict: dict, desired_key, prev_keys: tuple = tuple()): +def walk_dict_by_key(indict: dict, desired_key, prev_keys: tuple = ()): """ Walk an arbitrarily nested dictionary looking for the desired key yielding the squence of keys and the corresponding value. @@ -140,19 +158,17 @@ def walk_dict_by_key(indict: dict, desired_key, prev_keys: tuple = tuple()): if key == desired_key: yield prev_keys + (key,), val elif isinstance(val, dict): - for data in walk_dict_by_key(val, desired_key, prev_keys + (key,)): - yield data + yield from walk_dict_by_key(val, desired_key, prev_keys + (key,)) -def walk_dict_values(indict: dict, prev_keys: tuple = tuple()): +def walk_dict_values(indict: dict, prev_keys: tuple = ()): """ Walk an arbitrarily nested dictionary yielding all non dictionary values and the corresponding sequence of keys. """ for key, val in indict.items(): if isinstance(val, dict): - for data in walk_dict_values(val, prev_keys + (key,)): - yield data + yield from walk_dict_values(val, prev_keys + (key,)) else: yield prev_keys + (key,), val @@ -162,10 +178,10 @@ def determine_basis_cardinality(basis: str): def _dunnings(bas: str): try: b: list[str] = bas.split("-") - cstr: str = b[b.index("cc")+1] + cstr: str = b[b.index("cc") + 1] zetaidx: int = 2 zetaoffset: int = 0 - if cstr.startswith("pwv") or cstr.startswith("pcv"): + if cstr.startswith(("pwv", "pcv")): zetaidx += 1 if "(" in cstr: zetaidx += 1 @@ -181,17 +197,19 @@ def _dunnings(bas: str): except IndexError: pass - log.error(f"Basis set {bas} was interpreted as a Dunning's basis but " - "could not be identified!", - "Functions: Determine Basis Cardinality", - "Basis identification error") + log.error( + f"Basis set {bas} was interpreted as a Dunning's basis but " + "could not be identified!", + "Functions: Determine Basis Cardinality", + "Basis identification error", + ) return 0 # Karlsruhe def2 def _karlsruhe(bas: str): try: b: list[str] = bas.split("-") - cstr: str = b[b.index("def2")+1] + cstr: str = b[b.index("def2") + 1] zetaidx: int = 0 if cstr.startswith("m"): zetaidx += 1 @@ -206,10 +224,12 @@ def _karlsruhe(bas: str): except IndexError: pass - log.error(f"Basis set {bas} was interpreted as a Karlruhe basis but " - "could not be identified!", - "Functions: Determine Basis Cardinality", - "Basis identification error") + log.error( + f"Basis set {bas} was interpreted as a Karlruhe basis but " + "could not be identified!", + "Functions: Determine Basis Cardinality", + "Basis identification error", + ) return 0 b: str = basis.lower() @@ -221,7 +241,9 @@ def _karlsruhe(bas: str): if "def2" in b: return _karlsruhe(b) - log.error(f"Unknown basis format for {b}.", - "Functions: Determine Basis Cardinality", - "Basis identification error") + log.error( + f"Unknown basis format for {b}.", + "Functions: Determine Basis Cardinality", + "Basis identification error", + ) return 0 diff --git a/molbench/input_constructor.py b/molbench/input_constructor.py index c3110c4..14c551f 100644 --- a/molbench/input_constructor.py +++ b/molbench/input_constructor.py @@ -1,16 +1,15 @@ -"""Python file for input constructors. - -""" +"""Python file for input constructors.""" import json -from pathlib import Path from collections import Counter, defaultdict -from .assignment import new_assignment_file +from pathlib import Path + from . import logger as log +from .assignment import new_assignment_file from .configuration import config -from .functions import substitute_template, default_name_template -from .molecule import MoleculeList, Molecule -from .tree import Node, DummyNode +from .functions import default_name_template, substitute_template +from .molecule import Molecule, MoleculeList +from .tree import DummyNode, Node class InputConstructor: @@ -33,17 +32,23 @@ class InputConstructor: """ def create_inputs(self, *args, **kwargs) -> list: - raise NotImplementedError("The 'create_inputs' method is only " - "implemented on child classes.") + raise NotImplementedError( + "The 'create_inputs' method is only implemented on child classes." + ) def create_assignments(self, *args, **kwargs) -> list: - raise NotImplementedError("The 'create_assignments' method is only " - "implemented on child classes.") + raise NotImplementedError( + "The 'create_assignments' method is only implemented on child classes." + ) - def _create_files(self, data_iterable, basepath: str, - file_name_generator: callable, - file_content_generator: callable, - folder_structure_generator: callable): + def _create_files( + self, + data_iterable, + basepath: str, + file_name_generator: callable, + file_content_generator: callable, + folder_structure_generator: callable, + ): basepath: Path = Path(basepath).resolve() if not basepath.exists(): @@ -75,8 +80,9 @@ def _create_files(self, data_iterable, basepath: str, for content, name in zip(content_list, name_list): file = path / name if file.is_file(): - log.warning(f"Overwriting existing file {file}.", - "Input Constructor") + log.warning( + f"Overwriting existing file {file}.", "Input Constructor" + ) # write the file with open(file, "w") as f: f.write(content) @@ -91,11 +97,14 @@ def _folder_structure(variant_data: dict): for node in generation: val = variant_data.get(node.value, None) if val is None: - log.critical("Failed to resolve folder path for " - + f"{variant_data}.", "Input Constructor") + log.critical( + "Failed to resolve folder path for " + f"{variant_data}.", + "Input Constructor", + ) folder_name.append(node.to_string(val)) path /= "_".join(folder_name) return path + return _folder_structure @@ -122,17 +131,22 @@ def init_template(self, template: str): template_file = Path(template).resolve() # actually try to read the file try: - with open(template_file, 'r') as f: + with open(template_file, "r") as f: self.template = f.read() - except Exception: - log.critical(f"Template {template} could not be loaded.", - "Template Constructor") - - def create_inputs(self, benchmark: MoleculeList[Molecule], basepath: str, - calc_details: dict, - file_expansion_keys: tuple = ("basis",), - flat_structure: bool = False, - name_template: str | None = None) -> list: + except Exception: # noqa: BLE001 + log.critical( + f"Template {template} could not be loaded.", "Template Constructor" + ) + + def create_inputs( + self, + benchmark: MoleculeList[Molecule], + basepath: str, + calc_details: dict, + file_expansion_keys: tuple = ("basis",), + flat_structure: bool = False, + name_template: str | None = None, + ) -> list: """ Create inputs files for the provided set of Molecules by filling in the placeholders in the input template with data from the @@ -183,16 +197,24 @@ def create_inputs(self, benchmark: MoleculeList[Molecule], basepath: str, folder_structure_generator = self._gen_folder_structure(tree) - return self._create_files(variant_data_iterator, basepath, - file_name_generator, file_content_generator, - folder_structure_generator) + return self._create_files( + variant_data_iterator, + basepath, + file_name_generator, + file_content_generator, + folder_structure_generator, + ) - def create_assignments(self, benchmark: MoleculeList[Molecule], - basepath: str, calc_details: dict, - file_expansion_keys: tuple = ("basis",), - flat_structure: bool = False, - name_template: str | None = None, - transition_id_key="transition_id") -> list: + def create_assignments( + self, + benchmark: MoleculeList[Molecule], + basepath: str, + calc_details: dict, + file_expansion_keys: tuple = ("basis",), + flat_structure: bool = False, + name_template: str | None = None, + transition_id_key="transition_id", + ) -> list: """ Create assignment files for the provided set of Molecules. Note: This does currently not work for relative properties! @@ -242,13 +264,17 @@ def create_assignments(self, benchmark: MoleculeList[Molecule], tree = Node("name") folder_structure_generator = self._gen_folder_structure(tree) - return self._create_files(variant_data_iterator, basepath, - file_name_generator, file_content_generator, - folder_structure_generator) + return self._create_files( + variant_data_iterator, + basepath, + file_name_generator, + file_content_generator, + folder_structure_generator, + ) - def _molecule_variants_data_iter(self, benchmark: MoleculeList, - calc_details: dict, - file_expansion_keys: tuple): + def _molecule_variants_data_iter( + self, benchmark: MoleculeList, calc_details: dict, file_expansion_keys: tuple + ): # for each molecule: # find all unique combination of relevant keys, for instance, # perform calculations for different basis sets in independent @@ -258,13 +284,16 @@ def _molecule_variants_data_iter(self, benchmark: MoleculeList, variants = [] variant_properties = [] for property in molecule.state_data.values(): - var = tuple((key, property.get(key, None)) - for key in file_expansion_keys) + var = tuple( + (key, property.get(key, None)) for key in file_expansion_keys + ) if any(val is None for _, val in var): log.warning( f"Skipping a state of molecule {molecule.name}: " f"missing value(s) for {file_expansion_keys} " - f"(got {var}).", "TemplateConstructor") + f"(got {var}).", + "TemplateConstructor", + ) continue try: # no new variant -> add the property to the list i = variants.index(var) @@ -273,25 +302,32 @@ def _molecule_variants_data_iter(self, benchmark: MoleculeList, variants.append(var) variant_properties.append([property]) for var, props in zip(variants, variant_properties): - log.debug(f"Creating file for: {molecule.name} -> {var}.", - "Template Constructor") + log.debug( + f"Creating file for: {molecule.name} -> {var}.", + "Template Constructor", + ) # collect all the relevant data variant_data: dict = molecule.system_data.copy() if "name" in variant_data: - log.warning("The key 'name' in the molecules 'system_data'" - " is reservedfor the name of the molecule. " - "Overwriting existing value " - f"{variant_data['name']} with " - f"{molecule.name}.", "Template Constructor") + log.warning( + "The key 'name' in the molecules 'system_data'" + " is reservedfor the name of the molecule. " + "Overwriting existing value " + f"{variant_data['name']} with " + f"{molecule.name}.", + "Template Constructor", + ) variant_data["name"] = molecule.name # add the relevant subset of state_data for resolving the # current variant for key, val in var: if key in variant_data: - log.warning(f"Found conflicting entry for {key}. " - f"Overwriting existing value " - f"{variant_data[key]} with {val}.", - "TemplateConstructor") + log.warning( + f"Found conflicting entry for {key}. " + f"Overwriting existing value " + f"{variant_data[key]} with {val}.", + "TemplateConstructor", + ) variant_data[key] = val # add the additional data from the user variant_data.update(calc_details) @@ -307,6 +343,7 @@ def _substitute_template(self, template: str): def _substitute(data): subvals, _ = data return substitute_template(template, subvals) + return _substitute def _gen_file_names(self, name_template: str): @@ -335,6 +372,7 @@ def _name_generator(data) -> tuple[str, ...]: fname = f"{fname.stem}_{idx}{fname.suffix}" ret.append(fname) return tuple(ret) + return _name_generator def _gen_folder_structure(self, tree: Node): @@ -343,6 +381,7 @@ def _gen_folder_structure(self, tree: Node): def _gen_folders(data): variant_data, _ = data return generator(variant_data) + generator = self._folders_from_tree(tree) return _gen_folders @@ -355,25 +394,31 @@ def _gen_assignment(data: tuple[dict, list]) -> tuple[str]: for prop in properties: t_id = prop.get(transition_id_key, None) if t_id is None: - log.warning(f"Property of molecule {variant_data['name']} " - f"has no assignment: {prop}", - "Template Constructor") + log.warning( + f"Property of molecule {variant_data['name']} " + f"has no assignment: {prop}", + "Template Constructor", + ) continue if t_id not in transition_ids: # s_id has not to be hashable transition_ids.append(t_id) return (new_assignment_file(transition_ids),) + return _gen_assignment class CompressedTemplateConstructor(TemplateConstructor): - - def create_inputs(self, benchmark: MoleculeList, basepath: str, - calc_details: dict, - file_expansion_keys: tuple = ("basis",), - flat_structure: bool = False, - name_template: str | None = None, - reference_path: str = "references.json", - compressed_property: str | None = None) -> list: + def create_inputs( + self, + benchmark: MoleculeList, + basepath: str, + calc_details: dict, + file_expansion_keys: tuple = ("basis",), + flat_structure: bool = False, + name_template: str | None = None, + reference_path: str = "references.json", + compressed_property: str | None = None, + ) -> list: # Create compressed benchmark # We create a new MoleculeList where each Molecule contains # only one geometry. @@ -384,17 +429,22 @@ def create_inputs(self, benchmark: MoleculeList, basepath: str, references = {} def _unique(xyz: list, charge: int, mult: int) -> int: - all_xyzs = [(m.system_data["xyz"], - m.system_data["charge"], - m.system_data["multiplicity"]) for m in compressed] + all_xyzs = [ + ( + m.system_data["xyz"], + m.system_data["charge"], + m.system_data["multiplicity"], + ) + for m in compressed + ] xyz_list = (xyz, charge, mult) if xyz_list in all_xyzs: return all_xyzs.index(xyz_list) return -1 - + def _available_props(state_data: dict) -> list: - props = list() - for _, state in state_data.items(): + props = [] + for state in state_data.values(): props.extend(list(state["data"].keys())) return props @@ -409,18 +459,19 @@ def _available_props(state_data: dict) -> list: # Number of Molecules in mol n_mols = len(mol.system_data["xyz_list"]) - references[mol.name] = {"molecules": list(), - "factors": list()} + references[mol.name] = {"molecules": [], "factors": []} for i in range(n_mols): mol_counter = len(compressed) - idx = _unique(mol.system_data["xyz_list"][i], - mol.system_data["charge_list"][i], - mol.system_data["multiplicity_list"][i]) + idx = _unique( + mol.system_data["xyz_list"][i], + mol.system_data["charge_list"][i], + mol.system_data["multiplicity_list"][i], + ) if idx < 0: # Prepare new Molecule name = f"m{mol_counter:06d}" - system_data = dict() + system_data = {} for system_dp, system_val in mol.system_data.items(): if system_dp.endswith("_list"): sd = system_dp[:-5] # -5 to cut off "_list" @@ -437,44 +488,56 @@ def _available_props(state_data: dict) -> list: available_properties = _available_props(mol.state_data) - if (len(available_properties) > 1 and - compressed_property is None): - log.critical("Please specify a property key from which the" - + " stochiometry should be read", - "CompressedTemplateConstructor") - elif (compressed_property is not None and - compressed_property not in available_properties): - log.critical("compressed_property was not found in" - + f" Molecule {mol.name}", - "CompressedTemplateConstructor") + if len(available_properties) > 1 and compressed_property is None: + log.critical( + "Please specify a property key from which the" + + " stochiometry should be read", + "CompressedTemplateConstructor", + ) + elif ( + compressed_property is not None + and compressed_property not in available_properties + ): + log.critical( + "compressed_property was not found in" + + f" Molecule {mol.name}", + "CompressedTemplateConstructor", + ) if compressed_property is None: - pkey = list(mol.state_data.keys())[0] + pkey = next(iter(mol.state_data.keys())) else: - pkey = [k for k, v in mol.state_data.items() - if compressed_property in v["data"]][0] + pkey = next( + k + for k, v in mol.state_data.items() + if compressed_property in v["data"] + ) factor_key = None - if "stochiometry" in \ - mol.state_data[pkey]: + if "stochiometry" in mol.state_data[pkey]: factor_key = "stochiometry" - elif "factors" in \ - mol.state_data[pkey]: + elif "factors" in mol.state_data[pkey]: factor_key = "factors" if factor_key is None: - log.critical(f"Could not find a factor in state {pkey} of " - f"molecule {mol.name}.", - "CompressedTemplateConstructor") + log.critical( + f"Could not find a factor in state {pkey} of " + f"molecule {mol.name}.", + "CompressedTemplateConstructor", + ) stoch: list = mol.state_data[pkey][factor_key] references[mol.name]["factors"] = stoch - inputs = super().create_inputs(compressed, basepath, calc_details, - file_expansion_keys, flat_structure, - name_template) + inputs = super().create_inputs( + compressed, + basepath, + calc_details, + file_expansion_keys, + flat_structure, + name_template, + ) full_reference_path = Path(basepath) / Path(reference_path) with open(full_reference_path, "w") as f: - json.dump(references, f, ensure_ascii=True, indent=4, - sort_keys=True) + json.dump(references, f, ensure_ascii=True, indent=4, sort_keys=True) return inputs diff --git a/molbench/json_encoder.py b/molbench/json_encoder.py index 5594582..19d9d52 100644 --- a/molbench/json_encoder.py +++ b/molbench/json_encoder.py @@ -4,14 +4,14 @@ """ -from . import logger as log -from .molecule import Molecule, MoleculeList, Datapoint -from dataclasses import asdict import json + import numpy -class MolbenchJSONEncoder(json.JSONEncoder): +from .molecule import Datapoint, Molecule + +class MolbenchJSONEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, Datapoint): return {"value": obj.value, "unit": obj.unit} diff --git a/molbench/logger.py b/molbench/logger.py index 7778e2c..c9da8cd 100644 --- a/molbench/logger.py +++ b/molbench/logger.py @@ -1,20 +1,20 @@ import logging import os import sys - +from typing import ClassVar instance: logging.Logger | None = None class MolbenchFormatter(logging.Formatter): - COLORS = { - 'DEBUG': '\033[1;32m', # Green - 'INFO': '', # No color - 'WARNING': '\033[3;33m', # Orange - 'ERROR': '\033[1;31m', # Red - 'CRITICAL': '\033[1;35m' # Pink + COLORS: ClassVar = { + "DEBUG": "\033[1;32m", # Green + "INFO": "", # No color + "WARNING": "\033[3;33m", # Orange + "ERROR": "\033[1;31m", # Red + "CRITICAL": "\033[1;35m", # Pink } - RESET_SEQ = '\033[0m' + RESET_SEQ = "\033[0m" def format(self, record): levelname = record.levelname @@ -38,7 +38,6 @@ def __init_log_instance(): def debug(msg: str, cause=None): - global instance assert instance is not None if cause is None: instance.debug(msg) @@ -47,7 +46,6 @@ def debug(msg: str, cause=None): def info(msg: str, cause=None): - global instance assert instance is not None if cause is None: instance.info(msg) @@ -56,7 +54,6 @@ def info(msg: str, cause=None): def warning(msg: str, cause=None): - global instance assert instance is not None if cause is None: instance.warning(msg) @@ -65,7 +62,6 @@ def warning(msg: str, cause=None): def error(msg: str, cause=None, etype: str = ""): - global instance assert instance is not None if cause is None: instance.error(f"{msg} (Error type: {etype})") @@ -74,7 +70,6 @@ def error(msg: str, cause=None, etype: str = ""): def critical(msg: str, cause): - global instance assert instance is not None instance.critical(f"[{cause}] CRITICAL ERROR: {msg}\n") sys.exit(-1) diff --git a/molbench/molecule.py b/molbench/molecule.py index 2994195..9c17deb 100644 --- a/molbench/molecule.py +++ b/molbench/molecule.py @@ -1,20 +1,26 @@ from __future__ import annotations +from collections.abc import Callable +from typing import Any + from . import logger as log from .functions import walk_dict_by_key -from typing import Any, Callable class Molecule: - - __slots__ = ("name", "data_id", "system_data", "state_data") + __slots__ = ("data_id", "name", "state_data", "system_data") name: str data_id: str system_data: dict[str, Any] state_data: dict[str, Any] - def __init__(self, name: str, data_id: str, system_data: dict | None = None, - state_data: dict | None = None) -> None: + def __init__( + self, + name: str, + data_id: str, + system_data: dict | None = None, + state_data: dict | None = None, + ) -> None: self.name = name self.data_id = data_id # dict that contains all the information regarding the system: @@ -30,18 +36,19 @@ def __repr__(self): return f"{self.name}: {self.data_id}" @classmethod - def from_benchmark(cls, benchmark_entry: dict, - benchmark_id, molname=None) -> 'Molecule': + def from_benchmark( + cls, benchmark_entry: dict, benchmark_id, molname=None + ) -> Molecule: # molname is only used as backup if name is not defined in the # benchmark - system_data = {k: v for k, v in benchmark_entry.items() - if k != "properties"} + system_data = {k: v for k, v in benchmark_entry.items() if k != "properties"} # Check if this molecule is a multi-Molecule entry, # e. g. through relative energies # If so, it contains the usual entries suffixed by "_list" # i. e. "xyz_list", "multiplicity_list", "n_atoms_list" etc. - if "xyz_list" in system_data and \ - isinstance(system_data["xyz_list"], (list, tuple)): + if "xyz_list" in system_data and isinstance( + system_data["xyz_list"], (list, tuple) + ): system_data["xyz_list"] = [ "\n".join(xyz) if not isinstance(xyz, str) else xyz for xyz in system_data["xyz_list"] @@ -55,21 +62,29 @@ def from_benchmark(cls, benchmark_entry: dict, name = system_data["name"] del system_data["name"] if name is None: - log.critical("Name not specified in benchmark entry and not " - "provided as argument to the method.", - "Molecule: from_benchmark") + log.critical( + "Name not specified in benchmark entry and not " + "provided as argument to the method.", + "Molecule: from_benchmark", + ) properties = benchmark_entry.get("properties", None) return cls(name, benchmark_id, system_data, properties) @classmethod - def from_external(cls, external_system_data: dict[str, Any], - external_state_data: dict[str, Any], - data_id: str, molname: str) -> 'Molecule': + def from_external( + cls, + external_system_data: dict[str, Any], + external_state_data: dict[str, Any], + data_id: str, + molname: str, + ) -> Molecule: # Either system data or state data must exist if (not external_state_data) and (not external_system_data): - log.critical("Both state and system data dicts are empty. " - f"Molecule {molname}", "Molecule: from_external") + log.critical( + f"Both state and system data dicts are empty. Molecule {molname}", + "Molecule: from_external", + ) # We now define variables that are filled with the parsed data system_data: dict[str, Any] | None = None state_data: dict[str, Any] | None = None @@ -84,56 +99,76 @@ def from_external(cls, external_system_data: dict[str, Any], for state_key, state_values in external_state_data.items(): # Make sure that all keys are strings if not isinstance(state_key, str): - log.critical("All state data keys must be strings. " - f"{state_key} is of type {type(state_key)} " - f"in Molecule {molname}", - "Molecule: from_external") + log.critical( + "All state data keys must be strings. " + f"{state_key} is of type {type(state_key)} " + f"in Molecule {molname}", + "Molecule: from_external", + ) if not isinstance(state_values, dict): - log.critical("Incorrect state data structre. Found " - f"{type(state_values)} where there " - f"should be a dict in molecule {molname}", - "Molecule: from_external") + log.critical( + "Incorrect state data structre. Found " + f"{type(state_values)} where there " + f"should be a dict in molecule {molname}", + "Molecule: from_external", + ) # State_values must have the keywords method, basis and data if "method" not in state_values: - log.critical("\"method\" keyword is required in state " - f"data. State Key: {state_key}", - "Molecule: from_external") + log.critical( + '"method" keyword is required in state ' + f"data. State Key: {state_key}", + "Molecule: from_external", + ) if "basis" not in state_values: - log.critical("\"basis\" keyword is required in state " - f"data. State Key: {state_key}", - "Molecule: from_external") + log.critical( + '"basis" keyword is required in state ' + f"data. State Key: {state_key}", + "Molecule: from_external", + ) if "data" not in state_values: - log.critical("\"data\" keyword is required in state " - f"data. State Key: {state_key}", - "Molecule: from_external") + log.critical( + '"data" keyword is required in state ' + f"data. State Key: {state_key}", + "Molecule: from_external", + ) # Lastly, we assert that the "data" dict is correctly set up # It should contain a series of entries, each containing a pair # of entries labeled "value" and "unit" if not isinstance(state_values["data"], dict): - log.critical("\"data\" keyword in state dictionary must " - f"contain a dictionary (state: {state_key}, " - f"molecule: {molname})", - "Molecule: from_external") + log.critical( + '"data" keyword in state dictionary must ' + f"contain a dictionary (state: {state_key}, " + f"molecule: {molname})", + "Molecule: from_external", + ) for dkey, dvals in state_values["data"].items(): # only value, unit allowed if len(dvals) != 2: - log.critical("Incorrect datapoint specification: " - f"{dvals}", "Molecule: from_external") + log.critical( + f"Incorrect datapoint specification: {dvals}", + "Molecule: from_external", + ) if "unit" not in dvals: - log.critical("Incorrect datapoint specification: " - f"{dvals}", "Molecule: from_external") + log.critical( + f"Incorrect datapoint specification: {dvals}", + "Molecule: from_external", + ) if "value" not in dvals: - log.critical("Incorrect datapoint specification: " - f"{dvals}", "Molecule: from_external") - dpoint: Datapoint = Datapoint(dvals["value"], - str(dvals["unit"])) + log.critical( + f"Incorrect datapoint specification: {dvals}", + "Molecule: from_external", + ) + dpoint: Datapoint = Datapoint(dvals["value"], str(dvals["unit"])) state_data[state_key]["data"][dkey] = dpoint return cls(molname, data_id, system_data, state_data) - def add_assignments(self, assignments: dict, - old_transition_id_key: str = "transition_id", - new_transition_id_key: str = "assigned_transition_id") -> None: + def add_assignments( + self, + assignments: dict, + old_transition_id_key: str = "transition_id", + new_transition_id_key: str = "assigned_transition_id", + ) -> None: """ Add the state assignment to the state data, i.e., try to add an assignment for each property in the state data. @@ -150,9 +185,9 @@ def add_assignments(self, assignments: dict, (default: 'assigned_transition_id'). """ # We create a list to keep track of previously assigned states - prev_assigned = list() + prev_assigned = [] # We also create a list of unassigned states to pop - unassigned = list() + unassigned = [] # Next, we iterate over all states and look for the correct transition id for state_key, state_data in self.state_data.items(): @@ -162,39 +197,44 @@ def add_assignments(self, assignments: dict, # If the property is not found, it cannot be assigned tid = state_data[old_transition_id_key] if tid not in assignments: - log.warning(f"No assignment found for transition id {tid} of " - f"state {state_key}. Dropping the state.", - "Molecule: add_assignments") + log.warning( + f"No assignment found for transition id {tid} of " + f"state {state_key}. Dropping the state.", + "Molecule: add_assignments", + ) unassigned.append(state_key) continue # The assigned transition_id ass_tid = assignments[tid] if ass_tid in prev_assigned: - log.warning(f"The transition id key {tid} is assigned to multiple" - "transitions. Overwriting.", "Molecule: add_assignments") + log.warning( + f"The transition id key {tid} is assigned to multiple" + "transitions. Overwriting.", + "Molecule: add_assignments", + ) prev_assigned.append(ass_tid) state_data[new_transition_id_key] = ass_tid for key in unassigned: del self.state_data[key] -class MoleculeList(list[Molecule]): - def filter(self, key, *values) -> 'MoleculeList': +class MoleculeList(list[Molecule]): + def filter(self, key, *values) -> MoleculeList: if key == "name": # for compatability return self.filter_names(*values) elif key == "data_id": return self.filter_data_ids(*values) return self._filter(key, lambda v: v in values) - def remove(self, key, *values) -> 'MoleculeList': + def remove(self, key, *values) -> MoleculeList: if key == "name": # for compatability return self.remove_names(*values) elif key == "data_id": return self.remove_data_ids(*values) return self._filter(key, lambda v: v not in values) - def apply_stochiometry(self, stochiometry: dict) -> 'MoleculeList': + def apply_stochiometry(self, stochiometry: dict) -> MoleculeList: # TODO: some explanation: e.g., what is the expected form of the # stochiometry dict? combined_list = MoleculeList() @@ -213,28 +253,31 @@ def find_mol(name): log.critical( f"Number of molecules ({len(relevant_mol_names)}) does " f"not match number of factors ({len(factors)}) for " - f"stochiometry entry {c_name}.", "MoleculeList") + f"stochiometry entry {c_name}.", + "MoleculeList", + ) relevant_mols = [find_mol(name) for name in relevant_mol_names] - if any([x is None for x in relevant_mols]): - log.error(f"Could not find all molecules for {c_name}", - "MoleculeList") + if any(x is None for x in relevant_mols): + log.error(f"Could not find all molecules for {c_name}", "MoleculeList") continue - combined_mol = Molecule(c_name, data_id, dict(), dict()) + combined_mol = Molecule(c_name, data_id, {}, {}) for molidx, rmol in enumerate(relevant_mols): rmol: Molecule - self._join_system_data(combined_mol.system_data, - rmol.system_data, molidx) - self._join_state_data(combined_mol.state_data, rmol.state_data, - factors[molidx]) + self._join_system_data( + combined_mol.system_data, rmol.system_data, molidx + ) + self._join_state_data( + combined_mol.state_data, rmol.state_data, factors[molidx] + ) combined_list.append(combined_mol) return combined_list - def filter_by_range(self, key, min=None, max=None) -> 'MoleculeList': + def filter_by_range(self, key, min=None, max=None) -> MoleculeList: # here we don't know how to add elements to min and max # and we also don't know how anything about the value we want to # compare @@ -253,7 +296,7 @@ def _filter_range(value) -> bool: return self._filter(key, _filter_range) - def filter_by_vec_norm(self, key, min=None, max=None) -> 'MoleculeList': + def filter_by_vec_norm(self, key, min=None, max=None) -> MoleculeList: # filter according to the vector norm and only keep states with a norm # min <= norm <= max # min and max should be provided as list of numbers of arbitrary length @@ -291,7 +334,7 @@ def _filter_vec_norm(vec_norm) -> bool: if len(vec_norm) < len(norm_range): vec_norm = [ *vec_norm, - *(0 for _ in range(len(norm_range) - len(vec_norm))) + *(0 for _ in range(len(norm_range) - len(vec_norm))), ] # norm_range might be shorter than vec_norm for i, norm in enumerate(vec_norm): @@ -302,40 +345,40 @@ def _filter_vec_norm(vec_norm) -> bool: else: # user input is exhausted -> allow all norms break return True + return self._filter(key, _filter_vec_norm) - def filter_names(self, *names: str) -> 'MoleculeList': + def filter_names(self, *names: str) -> MoleculeList: return self._filter_names(lambda n: n in names) - def remove_names(self, *names: str) -> 'MoleculeList': + def remove_names(self, *names: str) -> MoleculeList: return self._filter_names(lambda n: n not in names) - def _filter_names(self, callback: Callable[[str], bool]) -> 'MoleculeList': + def _filter_names(self, callback: Callable[[str], bool]) -> MoleculeList: return MoleculeList(m for m in self if callback(m.name)) - def filter_data_ids(self, *ids: str) -> 'MoleculeList': + def filter_data_ids(self, *ids: str) -> MoleculeList: return self._filter_data_ids(lambda id: id in ids) - def remove_data_ids(self, *ids: str) -> 'MoleculeList': + def remove_data_ids(self, *ids: str) -> MoleculeList: return self._filter_data_ids(lambda id: id not in ids) - def _filter_data_ids(self, callback: Callable[[str], bool]) -> 'MoleculeList': + def _filter_data_ids(self, callback: Callable[[str], bool]) -> MoleculeList: return MoleculeList(m for m in self if callback(m.data_id)) - def filter_properties(self, *types: str) -> 'MoleculeList': + def filter_properties(self, *types: str) -> MoleculeList: return self._filter_properties(lambda ptype: ptype in types) - def remove_properties(self, *types: str) -> 'MoleculeList': + def remove_properties(self, *types: str) -> MoleculeList: return self._filter_properties(lambda ptype: ptype not in types) - def _filter_properties(self, callback: Callable[[str], bool]) -> 'MoleculeList': + def _filter_properties(self, callback: Callable[[str], bool]) -> MoleculeList: filtered = MoleculeList() for molecule in self: remaining_state_data = {} for state, data in molecule.state_data.items(): remaining_properties = { - k: v for k, v in data.get("data", {}).items() - if callback(k) + k: v for k, v in data.get("data", {}).items() if callback(k) } # no property left -> drop the state if remaining_properties: @@ -343,11 +386,14 @@ def _filter_properties(self, callback: Callable[[str], bool]) -> 'MoleculeList': new_data["data"] = remaining_properties remaining_state_data[state] = new_data if remaining_state_data: - filtered.append(Molecule( - name=molecule.name, data_id=molecule.data_id, - system_data=molecule.system_data, - state_data=remaining_state_data - )) + filtered.append( + Molecule( + name=molecule.name, + data_id=molecule.data_id, + system_data=molecule.system_data, + state_data=remaining_state_data, + ) + ) return filtered def _filter(self: list[Molecule], key, callback: Callable): @@ -357,27 +403,34 @@ def _filter(self: list[Molecule], key, callback: Callable): filtered = MoleculeList() for molecule in self: # start by checking system_data -> possibly drop the molecule - if not all(callback(val) for _, val in - walk_dict_by_key(molecule.system_data, key)): + if not all( + callback(val) for _, val in walk_dict_by_key(molecule.system_data, key) + ): continue # now check the state_data -> possibly drop multiple states # if no states are left we drop the whole molecule - remaining_states = [state for state, data in - molecule.state_data.items() - if all(callback(val) for _, val - in walk_dict_by_key(data, key))] + remaining_states = [ + state + for state, data in molecule.state_data.items() + if all(callback(val) for _, val in walk_dict_by_key(data, key)) + ] if not remaining_states: # no state left -> drop molecule continue # no state was removed if len(remaining_states) == len(molecule.state_data.keys()): filtered.append(molecule) else: # at least 1 state was removed - state_data = {state: molecule.state_data[state] - for state in remaining_states} - filtered.append(Molecule( - molecule.name, molecule.data_id, molecule.system_data, - state_data - )) + state_data = { + state: molecule.state_data[state] for state in remaining_states + } + filtered.append( + Molecule( + molecule.name, + molecule.data_id, + molecule.system_data, + state_data, + ) + ) return filtered def _join_system_data(self, dst_sys, src_sys, idx): @@ -425,13 +478,11 @@ def _join_state_data(self, dst_state, src_state, factor): # Create and cache a dictionary, which types of properties # are handled by which property keys in the destination state # property dictionary for easy lookup in the loop - keydict: dict[str, str] = dict() + keydict: dict[str, str] = {} for state_id, state_props in dst_state.items(): all_props = list(state_props["data"].keys()) - keydict.update( - {k: state_id for k in all_props} - ) - for key, val in src_state.items(): + keydict.update({k: state_id for k in all_props}) + for val in src_state.values(): # Extract the property value of the source state data # dictionary for out-of-place modification since the # same source state data dictionary can influence @@ -455,15 +506,15 @@ def _join_state_data(self, dst_state, src_state, factor): dst_dp = dst_state[keydict[datakey]]["data"][datakey] if isinstance(property_value, (list, tuple)): assert isinstance(dst_dp.value, (list, tuple)) - dst_dp.value = [p0 + p1 for p0, p1 in - zip(property_value, dst_dp.value)] + dst_dp.value = [ + p0 + p1 for p0, p1 in zip(property_value, dst_dp.value) + ] else: dst_dp.value += property_value class Datapoint: - - __slots__ = ("value", "unit") + __slots__ = ("unit", "value") value: Any unit: str @@ -477,8 +528,7 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: if not isinstance(other, Datapoint): return False - return (self.value == other.value) and (self.unit.lower() - == other.unit.lower()) + return (self.value == other.value) and (self.unit.lower() == other.unit.lower()) def __add__(self, other: Datapoint): if other.unit.lower() != self.unit.lower(): diff --git a/molbench/statistics.py b/molbench/statistics.py index b3046f5..5b39f12 100644 --- a/molbench/statistics.py +++ b/molbench/statistics.py @@ -1,30 +1,40 @@ -from .comparison import Comparison -from . import logger as log from collections import defaultdict -from typing import Callable +from collections.abc import Callable +from typing import ClassVar + import numpy +from . import logger as log +from .comparison import Comparison + class Statistics: """ Class for statistical evaluation of a data set. """ - available_error_measures = {} + available_error_measures: ClassVar = {} def __init__(self, data: Comparison) -> None: if not isinstance(data, Comparison): - log.critical("Data for statistics evaluation has to be provided " - f"as {Comparison}.", "Statistics") + log.critical( + f"Data for statistics evaluation has to be provided as {Comparison}.", + "Statistics", + ) self._data = data @property def data(self): return self._data - def compare(self, interest: dict, reference: dict, relative: bool = False, - relative_damping: float = 0.0, - error_thresh: float = 1) -> dict: + def compare( + self, + interest: dict, + reference: dict, + relative: bool = False, + relative_damping: float = 0.0, + error_thresh: float = 1, + ) -> dict: """ Computes the signed error for a subset of data as interest_value - reference_value. @@ -82,13 +92,17 @@ def compare(self, interest: dict, reference: dict, relative: bool = False, identifier = self.identify(interest, reference) interest_finder = self.get_interest_values(interest, reference) - return self._compare(identifier, interest_finder, relative=relative, - relative_damping=relative_damping, - error_thresh=error_thresh) + return self._compare( + identifier, + interest_finder, + relative=relative, + relative_damping=relative_damping, + error_thresh=error_thresh, + ) def identify(self, interest: dict, reference: dict) -> Callable: """Returns a Callable to identify whether a value is a reference or - interest value. + interest value. """ all_separators = self.data.structure @@ -100,18 +114,18 @@ def _identify(separators) -> str | None: return "interest" else: return None + return _identify def get_interest_values(self, interest, reference) -> Callable: """Returns a Callable that identifies the interest values that belong - to a given reference value. + to a given reference value. """ common_keys = interest.keys() & reference.keys() fixed_interest_separators = {k: interest[k] for k in common_keys} all_separators = self.data.structure - def _get_interest_values(ref_separators: list, - interest_pool: list) -> list: + def _get_interest_values(ref_separators: list, interest_pool: list) -> list: separators = [] for ref_sep, sep in zip(ref_separators, all_separators): # key already fixed in the input @@ -142,17 +156,23 @@ def _get_interest_values(ref_separators: list, for i in reversed(assigned_interest): del interest_pool[i] if len(interest_values) > 1: - log.warning("Found more than 1 interest value for reference " - f"value {ref_separators}.", - "Statistics: get_interest_values") + log.warning( + "Found more than 1 interest value for reference " + f"value {ref_separators}.", + "Statistics: get_interest_values", + ) return interest_values + return _get_interest_values - def _compare(self, identify: Callable, - get_interest_values: Callable, - relative: bool = False, - relative_damping: float = 0.0, - error_thresh: float = 1) -> dict: + def _compare( + self, + identify: Callable, + get_interest_values: Callable, + relative: bool = False, + relative_damping: float = 0.0, + error_thresh: float = 1, + ) -> dict: reference = [] interest = [] for keys, value in self.data.walk_values(): @@ -164,11 +184,10 @@ def _compare(self, identify: Callable, elif role[0] == "i": interest.append((tuple(keys), value)) else: - log.error(f"Could not assign a role to {keys}.", - "Statistics._compare") + log.error(f"Could not assign a role to {keys}.", "Statistics._compare") signed_errors = defaultdict(dict) - for (ref_keys, ref) in reference: + for ref_keys, ref in reference: interest_values = get_interest_values(ref_keys, interest) for interest_keys, values in interest_values: se = values - ref @@ -180,23 +199,31 @@ def _compare(self, identify: Callable, f"{relative_damping}) - cannot compute a " f"relative error for {interest_keys} vs " f"{ref_keys}. Skipping this pair.", - "Statistics._compare") + "Statistics._compare", + ) continue se /= denom if abs(se).value > error_thresh: - log.warning(f"Large Error detected: {se}\n" - f"Reference: {ref_keys}\n" - f"Interest: {interest_keys}\n" - f"Relative Error: {relative}\n" - f"Damping: {relative_damping}\n" - "Please check that all involved calculations " - "were successful.", "Statistics._compare") + log.warning( + f"Large Error detected: {se}\n" + f"Reference: {ref_keys}\n" + f"Interest: {interest_keys}\n" + f"Relative Error: {relative}\n" + f"Damping: {relative_damping}\n" + "Please check that all involved calculations " + "were successful.", + "Statistics._compare", + ) signed_errors[ref_keys][interest_keys] = se return signed_errors - def evaluate(self, signed_errors: dict, *statistical_error_measures, - assign: Callable | None = None, - proptype: str | None = None) -> dict: + def evaluate( + self, + signed_errors: dict, + *statistical_error_measures, + assign: Callable | None = None, + proptype: str | None = None, + ) -> dict: """ Evaluates statistical error measures for the given set of signed errors. Statistical error measures can be requested by @@ -207,19 +234,18 @@ def evaluate(self, signed_errors: dict, *statistical_error_measures, type ('energy', ...), which can be provided as another optional argument. """ - statistical_error_measures = set( + statistical_error_measures = { measure.lower() for measure in statistical_error_measures - ) + } if "all" in statistical_error_measures: - statistical_error_measures.update( - self.available_error_measures.keys() - ) + statistical_error_measures.update(self.available_error_measures.keys()) statistical_error_measures.remove("all") if assign is None: if proptype is None: - log.error("No assign Callable or proptype given.", - "Statistics: evaluate") + log.error( + "No assign Callable or proptype given.", "Statistics: evaluate" + ) return assign = self.assign_by_proptype(proptype) @@ -227,16 +253,22 @@ def evaluate(self, signed_errors: dict, *statistical_error_measures, for error_measure in statistical_error_measures: callback = self.available_error_measures.get(error_measure, None) if callback is None: - log.error("Can not evalute the unknown error measure " - f"{error_measure}.", "Statistics", "ValueError") + log.error( + f"Can not evalute the unknown error measure {error_measure}.", + "Statistics", + "ValueError", + ) continue ret[error_measure] = callback(signed_errors, assign) return ret - def extreme_error_keys(self, signed_errors: dict, - assign: Callable | None = None, - proptype: str | None = None, - absolute: bool = False) -> dict: + def extreme_error_keys( + self, + signed_errors: dict, + assign: Callable | None = None, + proptype: str | None = None, + absolute: bool = False, + ) -> dict: """ Finds the reference/interest key tuples (the expansion keys identifying a data point in the underlying Comparison, i.e., @@ -254,8 +286,10 @@ def extreme_error_keys(self, signed_errors: dict, """ if assign is None: if proptype is None: - log.error("No assign Callable or proptype given.", - "Statistics: extreme_error_keys") + log.error( + "No assign Callable or proptype given.", + "Statistics: extreme_error_keys", + ) return {} assign = self.assign_by_proptype(proptype) @@ -281,36 +315,40 @@ def _sortkey(entry): highest = entry return { - "min": {"reference": lowest[0], "interest": lowest[1], - "value": lowest[2]}, - "max": {"reference": highest[0], "interest": highest[1], - "value": highest[2]}, + "min": {"reference": lowest[0], "interest": lowest[1], "value": lowest[2]}, + "max": { + "reference": highest[0], + "interest": highest[1], + "value": highest[2], + }, } @staticmethod - def assign_by_proptype(intproptype: str, refproptype: str = None): + def assign_by_proptype(intproptype: str, refproptype: str | None = None): if refproptype is None: refproptype = intproptype def assign(refkeys: tuple, interestkeys: tuple) -> bool: - return ( - refkeys[-2] == refproptype and interestkeys[-2] == intproptype - ) + return refkeys[-2] == refproptype and interestkeys[-2] == intproptype + return assign def register_as_error_measure(function): """Decorator to register a function as error measure for statistical - data evaluation. + data evaluation. """ Statistics.available_error_measures[function.__name__.lower()] = function return function def _collect_errors(signed_errors: dict, assign: Callable) -> list: - return [value.value for refkeys, interest in signed_errors.items() - for interestkeys, value in interest.items() - if assign(refkeys, interestkeys)] + return [ + value.value + for refkeys, interest in signed_errors.items() + for interestkeys, value in interest.items() + if assign(refkeys, interestkeys) + ] @register_as_error_measure diff --git a/molbench/tree.py b/molbench/tree.py index 95f4a27..791b5ca 100644 --- a/molbench/tree.py +++ b/molbench/tree.py @@ -3,11 +3,12 @@ class Node: - def __init__(self, value, parent: 'Node' = None, - to_string: callable = None) -> None: + def __init__( + self, value, parent: "Node | None" = None, to_string: callable | None = None + ) -> None: self.value = value - self.children: list['Node'] = [] - self.parent: 'Node' | None = parent + self.children: list[Node] = [] + self.parent: Node | None = parent if parent is not None: parent.children.append(self) # callable to convert the found keys to string @@ -28,8 +29,7 @@ def traverse(self): # depth first algorithm yield self for child in self.children: - for n in child.traverse(): - yield n + yield from child.traverse() def traverse_breadth_first(self): # skip all DummyNodes @@ -49,10 +49,12 @@ def traverse_generations(self): if not isinstance(self, DummyNode): yield nodes while any(node.children for node in nodes): - nodes = tuple(chain.from_iterable( - (n for n in node.children if not isinstance(n, DummyNode)) - for node in nodes - )) + nodes = tuple( + chain.from_iterable( + (n for n in node.children if not isinstance(n, DummyNode)) + for node in nodes + ) + ) if nodes: # there are non dummy nodes in the generation yield nodes @@ -67,7 +69,7 @@ def sort(self, **kwargs): for child in self.children: child.sort(**kwargs) - def path_to_root(self) -> list['Node']: + def path_to_root(self) -> list["Node"]: # path to the root node including self and excluding DummyNodes path: list[Node] = [] if not isinstance(self, DummyNode): @@ -93,9 +95,9 @@ def width(self) -> int: class DummyNode(Node): - def __init__(self, parent: 'Node' = None) -> None: + def __init__(self, parent: "Node" = None) -> None: self.children = [] - self.parent: 'Node' | None = parent + self.parent: Node | None = parent if parent is not None: parent.children.append(self) @@ -105,5 +107,4 @@ def __str__(self): def traverse(self): # skip dummy nodes for child in self.children: - for n in child.traverse(): - yield n + yield from child.traverse() diff --git a/tests/conftest.py b/tests/conftest.py index bc70316..67f2f9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,15 @@ import json -import io + import pytest -from molbench.molecule import Molecule, MoleculeList, Datapoint -from molbench.comparison import Comparison +from molbench.comparison import Comparison +from molbench.molecule import Datapoint, Molecule, MoleculeList # --------------------------------------------------------------------------- # Minimal molecule fixtures # --------------------------------------------------------------------------- + @pytest.fixture def hydrogen_molecule(): return Molecule( @@ -137,20 +138,35 @@ def simple_template_file(tmp_path): # Known-delta Comparison fixture for statistics tests # --------------------------------------------------------------------------- + @pytest.fixture def known_comparison(): """Comparison with ref=-76.0 au (TBE) and interest=-75.9 au (HF). Signed error = interest - reference = +0.1 au. """ ref_mol = Molecule( - "water", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(-76.0, "au")}}} + "water", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(-76.0, "au")}, + } + }, ) int_mol = Molecule( - "water", "computed", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-75.9, "au")}}} + "water", + "computed", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-75.9, "au")}, + } + }, ) c = Comparison() c.add(MoleculeList([ref_mol, int_mol])) @@ -162,20 +178,58 @@ def two_molecule_comparison(): """Two molecules, errors +0.1 and +0.3 au. MSE=0.2, MAE=0.2, RMSD=sqrt(0.05)≈0.2236, min=0.1, max=0.3 """ - mols = MoleculeList([ - Molecule("water", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(-76.0, "au")}}}), - Molecule("benzene", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(-10.0, "au")}}}), - Molecule("water", "computed", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-75.9, "au")}}}), - Molecule("benzene", "computed", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-9.7, "au")}}}), - ]) + mols = MoleculeList( + [ + Molecule( + "water", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(-76.0, "au")}, + } + }, + ), + Molecule( + "benzene", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(-10.0, "au")}, + } + }, + ), + Molecule( + "water", + "computed", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-75.9, "au")}, + } + }, + ), + Molecule( + "benzene", + "computed", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-9.7, "au")}, + } + }, + ), + ] + ) c = Comparison() c.add(mols) return c diff --git a/tests/integration/test_benchmark_to_comparison.py b/tests/integration/test_benchmark_to_comparison.py index b17884a..66a7708 100644 --- a/tests/integration/test_benchmark_to_comparison.py +++ b/tests/integration/test_benchmark_to_comparison.py @@ -1,8 +1,10 @@ """Integration: load benchmark → filter → add to Comparison → walk.""" + import pytest + from molbench.benchmark_parser import JSONBenchmarkParser -from molbench.molecule import Datapoint from molbench.comparison import Comparison +from molbench.molecule import Datapoint @pytest.fixture(scope="module") @@ -19,6 +21,7 @@ def questdb(): # ascdb # --------------------------------------------------------------------------- + def test_ascdb_loads_and_populates_comparison(ascdb): c = Comparison() c.add(ascdb) @@ -41,8 +44,8 @@ def test_ascdb_filter_then_add(ascdb): c.add(filtered) assert len(c) > 0 # Only TBE molecules - for name, basis_dict in c.items(): - for basis, method_dict in basis_dict.items(): + for basis_dict in c.values(): + for method_dict in basis_dict.values(): assert "TBE" in method_dict or list(method_dict.keys()) @@ -50,6 +53,7 @@ def test_ascdb_filter_then_add(ascdb): # questdb # --------------------------------------------------------------------------- + def test_questdb_loads(questdb): assert len(questdb) > 0 @@ -73,7 +77,7 @@ def test_questdb_energy_values_are_datapoints(questdb): energy_entries = list(c.walk_by_key("excitation_energy")) assert len(energy_entries) > 0 for _, val_dict in energy_entries: - for _, v in val_dict.items(): + for v in val_dict.values(): assert isinstance(v, Datapoint) @@ -81,9 +85,8 @@ def test_questdb_energy_values_are_datapoints(questdb): # stochiometry pipeline (ascdb has stochiometry entries) # --------------------------------------------------------------------------- + def test_ascdb_stochiometry_entries_exist(ascdb): """Some ascdb entries use stochiometry (multi-geometry).""" - has_xyz_list = any( - "xyz_list" in mol.system_data for mol in ascdb - ) + has_xyz_list = any("xyz_list" in mol.system_data for mol in ascdb) assert has_xyz_list diff --git a/tests/integration/test_compare_evaluate.py b/tests/integration/test_compare_evaluate.py index 6409ca6..412a076 100644 --- a/tests/integration/test_compare_evaluate.py +++ b/tests/integration/test_compare_evaluate.py @@ -1,11 +1,12 @@ """Integration: Statistics.compare → evaluate with known analytical results.""" -import pytest + import numpy as np -from molbench.molecule import Molecule, MoleculeList, Datapoint +import pytest + from molbench.comparison import Comparison +from molbench.molecule import Datapoint, Molecule, MoleculeList from molbench.statistics import Statistics - INTEREST = {"method": "HF"} REFERENCE = {"method": "TBE"} PROPTYPE = "energy" @@ -15,12 +16,34 @@ def _build_comparison(pairs): """Build a Comparison from (name, ref_energy, int_energy) triples (au).""" mols = MoleculeList() for name, ref_e, int_e in pairs: - mols.append(Molecule(name, "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(ref_e, "au")}}})) - mols.append(Molecule(name, "computed", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(int_e, "au")}}})) + mols.append( + Molecule( + name, + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(ref_e, "au")}, + } + }, + ) + ) + mols.append( + Molecule( + name, + "computed", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(int_e, "au")}, + } + }, + ) + ) c = Comparison() c.add(mols) return c @@ -36,6 +59,7 @@ def _eval(c, *measures): # Single molecule: delta = +0.1 # --------------------------------------------------------------------------- + def test_mse_single_delta(): c = _build_comparison([("water", -76.0, -75.9)]) result = _eval(c, "mse") @@ -65,6 +89,7 @@ def test_sde_single_point(): # Two molecules: errors +0.1 and +0.3 # --------------------------------------------------------------------------- + def test_mse_two_molecules(): c = _build_comparison([("water", -76.0, -75.9), ("benzene", -10.0, -9.7)]) result = _eval(c, "mse") @@ -109,6 +134,7 @@ def test_evaluate_all_keyword(): # Relative errors # --------------------------------------------------------------------------- + def test_relative_error(): c = _build_comparison([("water", -76.0, -75.9)]) stats = Statistics(c) @@ -131,6 +157,7 @@ def test_relative_error_with_damping(): # Empty comparison # --------------------------------------------------------------------------- + def test_empty_interest_empty_errors(): c = _build_comparison([("water", -76.0, -75.9)]) stats = Statistics(c) diff --git a/tests/integration/test_full_pipeline_pyscf.py b/tests/integration/test_full_pipeline_pyscf.py index fd6fde4..23c4889 100644 --- a/tests/integration/test_full_pipeline_pyscf.py +++ b/tests/integration/test_full_pipeline_pyscf.py @@ -3,25 +3,27 @@ Marked @pytest.mark.slow — runs in its own CI job with pyscf installed. Skipped automatically if pyscf is not installed. """ + import json -import pytest from pathlib import Path -pyscf = pytest.importorskip("pyscf", reason="pyscf not installed") +import pytest -from pyscf import gto, scf # noqa: E402 +pyscf = pytest.importorskip("pyscf", reason="pyscf not installed") -from molbench.benchmark_parser import JSONBenchmarkParser # noqa: E402 -from molbench.external_parser import ExternalParser # noqa: E402 -from molbench.comparison import Comparison # noqa: E402 -from molbench.statistics import Statistics # noqa: E402 -from molbench.molecule import MoleculeList # noqa: E402 +from pyscf import gto, scf +from molbench.benchmark_parser import JSONBenchmarkParser +from molbench.comparison import Comparison +from molbench.external_parser import ExternalParser +from molbench.molecule import MoleculeList +from molbench.statistics import Statistics # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _json_parser(filepath): """Parse a JSON .out file written by the test itself.""" raw = json.loads(Path(filepath).read_text()) @@ -31,9 +33,7 @@ def _json_parser(filepath): "gs": { "basis": raw["basis"], "method": raw["method"], - "data": { - "energy": {"value": raw["data"]["energy"], "unit": "au"} - }, + "data": {"energy": {"value": raw["data"]["energy"], "unit": "au"}}, } } return name, system_data, state_data @@ -61,6 +61,7 @@ def _run_hf(xyz_str, basis, charge, spin, unit="A"): # Tests # --------------------------------------------------------------------------- + @pytest.mark.slow def test_hf_energy_on_h_atom_vs_ascdb(tmp_path): """HF/cc-pVDZ energy for the H atom should be within 0.05 au of the TBE.""" @@ -80,16 +81,21 @@ def test_hf_energy_on_h_atom_vs_ascdb(tmp_path): # Write a mock .out file out_file = tmp_path / "AE18pE-1_HF_cc-pvdz.out" - out_file.write_text(json.dumps({ - "name": "AE18pE-1", - "basis": "cc-pvdz", - "method": "HF", - "data": {"energy": hf_energy}, - })) + out_file.write_text( + json.dumps( + { + "name": "AE18pE-1", + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": hf_energy}, + } + ) + ) # Load via ExternalParser - computed = ExternalParser().load(str(tmp_path), parser=_json_parser, - out_suffix=".out") + computed = ExternalParser().load( + str(tmp_path), parser=_json_parser, out_suffix=".out" + ) assert len(computed) == 1 # Build Comparison @@ -107,9 +113,7 @@ def test_hf_energy_on_h_atom_vs_ascdb(tmp_path): mae_val, count = result["mae"] assert count == 1 - assert abs(mae_val) < 0.05, ( - f"HF/cc-pVDZ MAE for H atom too large: {mae_val:.6f} au" - ) + assert abs(mae_val) < 0.05, f"HF/cc-pVDZ MAE for H atom too large: {mae_val:.6f} au" @pytest.mark.slow @@ -119,9 +123,9 @@ def test_hf_energy_on_water_vs_ascdb(tmp_path): # Filter to a single-geometry water-like molecule from AE18 # We'll pick a molecule with a single geometry and charge=0 single_geo = [ - m for m in bench - if "xyz" in m.system_data - and m.system_data.get("charge", -99) == 0 + m + for m in bench + if "xyz" in m.system_data and m.system_data.get("charge", -99) == 0 ] if not single_geo: pytest.skip("No suitable single-geometry neutral molecule found in ascdb") @@ -140,19 +144,24 @@ def test_hf_energy_on_water_vs_ascdb(tmp_path): try: hf_energy = _run_hf(xyz, basis, charge, spin) - except Exception as e: + except Exception as e: # noqa: BLE001 pytest.skip(f"PySCF calculation failed: {e}") out_file = tmp_path / f"{target.name}_HF_{basis}.out" - out_file.write_text(json.dumps({ - "name": target.name, - "basis": basis, - "method": "HF", - "data": {"energy": hf_energy}, - })) - - computed = ExternalParser().load(str(tmp_path), parser=_json_parser, - out_suffix=".out") + out_file.write_text( + json.dumps( + { + "name": target.name, + "basis": basis, + "method": "HF", + "data": {"energy": hf_energy}, + } + ) + ) + + computed = ExternalParser().load( + str(tmp_path), parser=_json_parser, out_suffix=".out" + ) assert len(computed) == 1 mol_bench = MoleculeList([target]) diff --git a/tests/integration/test_input_generation_pipeline.py b/tests/integration/test_input_generation_pipeline.py index fa4e117..9e3e530 100644 --- a/tests/integration/test_input_generation_pipeline.py +++ b/tests/integration/test_input_generation_pipeline.py @@ -1,10 +1,16 @@ """Integration: load benchmark → generate input/assignment files → verify.""" + import json -import pytest from pathlib import Path -from molbench.benchmark_parser import JSONBenchmarkParser -from molbench.input_constructor import TemplateConstructor, CompressedTemplateConstructor + +import pytest + from molbench.assignment import parse_assignment_file +from molbench.benchmark_parser import JSONBenchmarkParser +from molbench.input_constructor import ( + CompressedTemplateConstructor, + TemplateConstructor, +) @pytest.fixture(scope="module") @@ -88,6 +94,7 @@ def test_generate_inputs_flat_structure(ascdb, tmp_path): # create_assignments with questdb (has transition_ids) # --------------------------------------------------------------------------- + def test_generate_assignments_from_questdb(questdb, tmp_path): tc = TemplateConstructor("pyscf_ordmp2") small = questdb[:3] @@ -120,6 +127,7 @@ def test_assignment_file_contains_transition_ids(questdb, tmp_path): # CompressedTemplateConstructor with ascdb (has xyz_list entries) # --------------------------------------------------------------------------- + def test_compressed_creates_references_json(ascdb, tmp_path): tc = CompressedTemplateConstructor("pyscf_ordmp2") # Pick only multi-geometry molecules @@ -127,8 +135,13 @@ def test_compressed_creates_references_json(ascdb, tmp_path): if not multi: pytest.skip("No multi-geometry molecules in ascdb slice") from molbench.molecule import MoleculeList - tc.create_inputs(MoleculeList(multi[:2]), str(tmp_path), PYSCF_CALC, - reference_path="references.json") + + tc.create_inputs( + MoleculeList(multi[:2]), + str(tmp_path), + PYSCF_CALC, + reference_path="references.json", + ) assert (tmp_path / "references.json").exists() @@ -138,8 +151,13 @@ def test_references_json_maps_to_molecule_names(ascdb, tmp_path): if not multi: pytest.skip("No multi-geometry molecules in ascdb slice") from molbench.molecule import MoleculeList - tc.create_inputs(MoleculeList(multi[:2]), str(tmp_path), PYSCF_CALC, - reference_path="references.json") + + tc.create_inputs( + MoleculeList(multi[:2]), + str(tmp_path), + PYSCF_CALC, + reference_path="references.json", + ) refs = json.loads((tmp_path / "references.json").read_text()) original_names = {m.name for m in multi[:2]} assert any(name in refs for name in original_names) diff --git a/tests/unit/test_assignment.py b/tests/unit/test_assignment.py index c6553fe..e995bd5 100644 --- a/tests/unit/test_assignment.py +++ b/tests/unit/test_assignment.py @@ -1,11 +1,12 @@ import pytest -from molbench.assignment import new_assignment_file, parse_assignment_file +from molbench.assignment import new_assignment_file, parse_assignment_file # --------------------------------------------------------------------------- # new_assignment_file # --------------------------------------------------------------------------- + def test_new_assignment_file_header(): content = new_assignment_file([]) assert content.startswith("# ref_state_id ==>") @@ -34,6 +35,7 @@ def test_new_assignment_file_format(): # parse_assignment_file # --------------------------------------------------------------------------- + def _write_ass(tmp_path, content, filename="test.ass"): f = tmp_path / filename f.write_text(content) diff --git a/tests/unit/test_bash_wrapper.py b/tests/unit/test_bash_wrapper.py index 66dc54f..aeaafd1 100644 --- a/tests/unit/test_bash_wrapper.py +++ b/tests/unit/test_bash_wrapper.py @@ -1,22 +1,25 @@ import io import os import subprocess -import pytest from pathlib import Path +import pytest # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _make_fake_run(sh_suffix=".sh"): """Returns a subprocess.run mock that creates a script file in the current working directory (which is the input file's directory during the create_bash_files call), simulating a submit-script generator.""" - def fake_run(cmd, shell=False): + + def fake_run(cmd, shell=False, check=False): infilename = cmd.strip().split()[-1] stem = Path(infilename).stem Path(stem + sh_suffix).write_text("#!/bin/bash\n#SBATCH " + stem) + return fake_run @@ -24,12 +27,13 @@ def fake_run(cmd, shell=False): # create_bash_files # --------------------------------------------------------------------------- + def test_create_bash_files_basic(tmp_path, monkeypatch): infile = tmp_path / "molA_HF_cc-pvdz.in" infile.write_text("fake input") - monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", - _make_fake_run(".sh")) + monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", _make_fake_run(".sh")) from molbench.bash_wrapper import create_bash_files + result = create_bash_files([str(infile)], "fakegen") assert len(result) == 1 assert result[0].endswith(".sh") @@ -39,9 +43,11 @@ def test_create_bash_files_basic(tmp_path, monkeypatch): def test_create_bash_files_sbatch_suffix(tmp_path, monkeypatch): infile = tmp_path / "mol.in" infile.write_text("") - monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", - _make_fake_run(".sbatch")) + monkeypatch.setattr( + "molbench.bash_wrapper.subprocess.run", _make_fake_run(".sbatch") + ) from molbench.bash_wrapper import create_bash_files + result = create_bash_files([str(infile)], "fakegen") assert len(result) == 1 assert result[0].endswith(".sbatch") @@ -51,13 +57,14 @@ def test_create_bash_files_both_sh_and_sbatch(tmp_path, monkeypatch): infile = tmp_path / "mol.in" infile.write_text("") - def fake_run_both(cmd, shell=False): + def fake_run_both(cmd, shell=False, check=False): stem = Path(cmd.strip().split()[-1]).stem Path(stem + ".sh").write_text("#!/bin/bash") Path(stem + ".sbatch").write_text("#!/bin/bash") monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", fake_run_both) from molbench.bash_wrapper import create_bash_files + result = create_bash_files([str(infile)], "fakegen") assert len(result) == 2 extensions = {Path(r).suffix for r in result} @@ -70,14 +77,14 @@ def test_create_bash_files_stem_filter(tmp_path, monkeypatch): infile = tmp_path / "target.in" infile.write_text("") - def fake_run_creates_both(cmd, shell=False): + def fake_run_creates_both(cmd, shell=False, check=False): # Creates target.sh AND unrelated_other.sh Path("target.sh").write_text("#!/bin/bash") Path("unrelated_other.sh").write_text("#!/bin/bash") - monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", - fake_run_creates_both) + monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", fake_run_creates_both) from molbench.bash_wrapper import create_bash_files + result = create_bash_files([str(infile)], "fakegen") assert all("target" in r for r in result) assert not any("unrelated_other" in r for r in result) @@ -89,18 +96,20 @@ def test_create_bash_files_multiple_inputs(tmp_path, monkeypatch): f = tmp_path / f"{name}.in" f.write_text("") files.append(str(f)) - monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", - _make_fake_run(".sh")) + monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", _make_fake_run(".sh")) from molbench.bash_wrapper import create_bash_files + result = create_bash_files(files, "fakegen") assert len(result) == 3 def test_create_bash_files_empty_list(monkeypatch): called = [] - monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", - lambda *a, **kw: called.append(1)) + monkeypatch.setattr( + "molbench.bash_wrapper.subprocess.run", lambda *a, **kw: called.append(1) + ) from molbench.bash_wrapper import create_bash_files + result = create_bash_files([], "fakegen") assert result == [] assert called == [] @@ -109,10 +118,10 @@ def test_create_bash_files_empty_list(monkeypatch): def test_create_bash_files_restores_cwd(tmp_path, monkeypatch): infile = tmp_path / "mol.in" infile.write_text("") - monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", - lambda *a, **kw: None) + monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", lambda *a, **kw: None) original_cwd = os.getcwd() from molbench.bash_wrapper import create_bash_files + create_bash_files([str(infile)], "fakegen") assert os.getcwd() == original_cwd @@ -120,9 +129,9 @@ def test_create_bash_files_restores_cwd(tmp_path, monkeypatch): def test_create_bash_files_returns_absolute_paths(tmp_path, monkeypatch): infile = tmp_path / "mol.in" infile.write_text("") - monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", - _make_fake_run(".sh")) + monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", _make_fake_run(".sh")) from molbench.bash_wrapper import create_bash_files + result = create_bash_files([str(infile)], "fakegen") for path in result: assert os.path.isabs(path) @@ -134,11 +143,12 @@ def test_create_bash_files_config_substitution(tmp_path, monkeypatch): infile.write_text("") received_cmds = [] - def capturing_run(cmd, shell=False): + def capturing_run(cmd, shell=False, check=False): received_cmds.append(cmd) monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", capturing_run) from molbench.bash_wrapper import create_bash_files + create_bash_files([str(infile)], "run -n [[threads]] mol") assert len(received_cmds) == 1 # [[threads]] should be replaced; the default config has threads=1 @@ -150,8 +160,10 @@ def capturing_run(cmd, shell=False): # make_send_script # --------------------------------------------------------------------------- + def test_make_send_script_shebang(): from molbench.bash_wrapper import make_send_script + buf = io.StringIO() make_send_script([], "sbatch", buf) assert buf.getvalue().startswith("#!/bin/bash") @@ -159,6 +171,7 @@ def test_make_send_script_shebang(): def test_make_send_script_function_definition(): from molbench.bash_wrapper import make_send_script + buf = io.StringIO() make_send_script([], "sbatch", buf) assert "function cd_and_sbatch()" in buf.getvalue() @@ -166,6 +179,7 @@ def test_make_send_script_function_definition(): def test_make_send_script_contains_all_files(tmp_path): from molbench.bash_wrapper import make_send_script + files = [str(tmp_path / "a.sh"), str(tmp_path / "b.sbatch")] buf = io.StringIO() make_send_script(files, "sbatch", buf) @@ -176,17 +190,18 @@ def test_make_send_script_contains_all_files(tmp_path): def test_make_send_script_empty_list(): from molbench.bash_wrapper import make_send_script + buf = io.StringIO() make_send_script([], "sbatch", buf) content = buf.getvalue() # Only header + function; no cd_and_sbatch calls - call_lines = [l for l in content.splitlines() - if l.startswith("cd_and_sbatch")] + call_lines = [l for l in content.splitlines() if l.startswith("cd_and_sbatch")] assert call_lines == [] def test_make_send_script_writes_to_stringio(tmp_path): from molbench.bash_wrapper import make_send_script + buf = io.StringIO() make_send_script([str(tmp_path / "x.sh")], "sbatch", buf) assert len(buf.getvalue()) > 0 @@ -194,6 +209,7 @@ def test_make_send_script_writes_to_stringio(tmp_path): def test_make_send_script_writes_to_real_file(tmp_path): from molbench.bash_wrapper import make_send_script + out = tmp_path / "send.sh" with open(out, "w") as f: make_send_script([str(tmp_path / "x.sh")], "sbatch", f) @@ -203,6 +219,7 @@ def test_make_send_script_writes_to_real_file(tmp_path): def test_make_send_script_send_command_substitution(): """[[threads]] in send_command is resolved from config.""" from molbench.bash_wrapper import make_send_script + buf = io.StringIO() make_send_script([], "sbatch --ntasks [[threads]]", buf) content = buf.getvalue() @@ -215,9 +232,12 @@ def test_create_bash_files_nonzero_returncode_logs_error(tmp_path, monkeypatch, infile.write_text("") monkeypatch.setattr( "molbench.bash_wrapper.subprocess.run", - lambda cmd, shell=False: subprocess.CompletedProcess(args=cmd, returncode=127), + lambda cmd, shell=False, check=False: subprocess.CompletedProcess( + args=cmd, returncode=127 + ), ) from molbench.bash_wrapper import create_bash_files + with caplog.at_level("ERROR", logger="molbench"): result = create_bash_files([str(infile)], "definitely_not_a_real_command") assert result == [] @@ -230,9 +250,9 @@ def test_create_bash_files_bare_filename_no_directory(tmp_path, monkeypatch): infile = tmp_path / "mol.in" infile.write_text("") monkeypatch.chdir(tmp_path) - monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", - _make_fake_run(".sh")) + monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", _make_fake_run(".sh")) from molbench.bash_wrapper import create_bash_files + result = create_bash_files(["mol.in"], "fakegen") assert len(result) == 1 @@ -241,12 +261,13 @@ def test_create_bash_files_restores_cwd_on_exception(tmp_path, monkeypatch): infile = tmp_path / "mol.in" infile.write_text("") - def raising_run(cmd, shell=False): + def raising_run(cmd, shell=False, check=False): raise RuntimeError("boom") monkeypatch.setattr("molbench.bash_wrapper.subprocess.run", raising_run) original_cwd = os.getcwd() from molbench.bash_wrapper import create_bash_files + with pytest.raises(RuntimeError): create_bash_files([str(infile)], "fakegen") assert os.getcwd() == original_cwd @@ -254,6 +275,7 @@ def raising_run(cmd, shell=False): def test_make_send_script_uses_abs_path(tmp_path): from molbench.bash_wrapper import make_send_script + f = tmp_path / "job.sh" buf = io.StringIO() make_send_script([str(f)], "sbatch", buf) diff --git a/tests/unit/test_benchmark_parser.py b/tests/unit/test_benchmark_parser.py index 25ebedd..e311445 100644 --- a/tests/unit/test_benchmark_parser.py +++ b/tests/unit/test_benchmark_parser.py @@ -1,6 +1,6 @@ -import json import pytest -from molbench.benchmark_parser import JSONBenchmarkParser, BenchmarkParser + +from molbench.benchmark_parser import BenchmarkParser, JSONBenchmarkParser from molbench.molecule import Molecule, MoleculeList @@ -13,6 +13,7 @@ def parser(): # Built-in benchmarks # --------------------------------------------------------------------------- + def test_load_ascdb(parser): ml = parser.load("ascdb") assert isinstance(ml, MoleculeList) @@ -49,6 +50,7 @@ def test_questdb_has_transition_ids(parser): # Custom file # --------------------------------------------------------------------------- + def test_load_from_file_path(parser, minimal_benchmark_file): ml = parser.load(minimal_benchmark_file, benchmark_id="test_bench") assert len(ml) == 2 @@ -75,6 +77,7 @@ def test_molecule_names_are_keys(parser, minimal_benchmark_file): # Error paths # --------------------------------------------------------------------------- + def test_load_nonexistent_exits(parser): with pytest.raises(SystemExit): parser.load("/nonexistent/path/bench.json") @@ -103,6 +106,7 @@ def test_use_local_benchmark_skips_premade(parser, minimal_benchmark_file): # premade_benchmarks discovery # --------------------------------------------------------------------------- + def test_premade_benchmarks_discovered(parser): BenchmarkParser._collect_premade_benchmarks() assert "ascdb" in parser.premade_benchmarks @@ -110,7 +114,7 @@ def test_premade_benchmarks_discovered(parser): def test_premade_benchmarks_are_json_paths(parser): - for key, path in parser.premade_benchmarks.items(): + for path in parser.premade_benchmarks.values(): assert path.endswith(".json") @@ -118,6 +122,7 @@ def test_premade_benchmarks_are_json_paths(parser): # multi-geometry benchmark # --------------------------------------------------------------------------- + def test_load_xyz_list_benchmark(parser, minimal_benchmark_list_file): ml = parser.load(minimal_benchmark_list_file) assert len(ml) == 1 diff --git a/tests/unit/test_comparison.py b/tests/unit/test_comparison.py index 4346c84..3a47755 100644 --- a/tests/unit/test_comparison.py +++ b/tests/unit/test_comparison.py @@ -1,7 +1,8 @@ -import pytest import numpy -from molbench.molecule import Molecule, MoleculeList, Datapoint +import pytest + from molbench.comparison import Comparison +from molbench.molecule import Datapoint, Molecule, MoleculeList def _mol(name, data_id, basis, method, energy, unit="au", transition_id=None): @@ -19,6 +20,7 @@ def _mol(name, data_id, basis, method, energy, unit="au", transition_id=None): # Initialization # --------------------------------------------------------------------------- + def test_default_separators(): c = Comparison() assert c.data_separators == ("basis", "method") @@ -46,6 +48,7 @@ def test_structure_property(): # add_molecule / add # --------------------------------------------------------------------------- + def test_add_molecule_inserts_at_correct_path(): c = Comparison() mol = _mol("water", "bench", "cc-pvdz", "HF", -76.0) @@ -67,10 +70,12 @@ def test_add_molecule_value_is_datapoint(): def test_add_molecule_list(): c = Comparison() - ml = MoleculeList([ - _mol("water", "bench", "cc-pvdz", "HF", -76.0), - _mol("benzene", "bench", "cc-pvdz", "HF", -10.0), - ]) + ml = MoleculeList( + [ + _mol("water", "bench", "cc-pvdz", "HF", -76.0), + _mol("benzene", "bench", "cc-pvdz", "HF", -10.0), + ] + ) c.add(ml) assert "water" in c and "benzene" in c @@ -93,14 +98,18 @@ def test_add_molecule_duplicate_data_id_warns(recwarn): def test_add_molecule_assigned_transition_id_overrides(): mol = Molecule( - "mol", "bench", {}, - {"gs": { - "basis": "cc-pvdz", - "method": "HF", - "data": {"energy": Datapoint(-1.0, "au")}, - "transition_id": "old_tid", - "assigned_transition_id": "new_tid", - }} + "mol", + "bench", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-1.0, "au")}, + "transition_id": "old_tid", + "assigned_transition_id": "new_tid", + } + }, ) c = Comparison("basis", "method", "transition_id") c.add_molecule(mol) @@ -111,9 +120,10 @@ def test_add_molecule_assigned_transition_id_overrides(): def test_add_molecule_none_separator_skipped(): # State is missing a separator key → silently skipped mol = Molecule( - "mol", "bench", {}, - {"gs": {"method": "HF", - "data": {"energy": Datapoint(-1.0, "au")}}} + "mol", + "bench", + {}, + {"gs": {"method": "HF", "data": {"energy": Datapoint(-1.0, "au")}}}, # no "basis" key → separator value is None ) c = Comparison() # separators: basis, method @@ -125,6 +135,7 @@ def test_add_molecule_none_separator_skipped(): # walk_by_key / walk_values # --------------------------------------------------------------------------- + def test_walk_by_key_finds_energy(simple_comparison): results = list(simple_comparison.walk_by_key("energy")) assert len(results) > 0 @@ -147,6 +158,7 @@ def test_walk_values_count(simple_comparison): # _import_value # --------------------------------------------------------------------------- + def test_import_value_scalar(): c = Comparison() assert c._import_value(1) == 1 @@ -188,6 +200,7 @@ def test_import_value_malformed_dict_logs_error(caplog): # add_molecule — invalid input type # --------------------------------------------------------------------------- + def test_add_molecule_wrong_type_logs_error_and_returns(caplog): c = Comparison() with caplog.at_level("ERROR", logger="molbench"): diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 9291b69..b6ed7fc 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,5 +1,7 @@ import json + import pytest + from molbench.configuration import Configuration diff --git a/tests/unit/test_datapoint.py b/tests/unit/test_datapoint.py index 3b40d02..82285d1 100644 --- a/tests/unit/test_datapoint.py +++ b/tests/unit/test_datapoint.py @@ -1,4 +1,5 @@ import pytest + from molbench.molecule import Datapoint diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py index 44ebbf0..86dd858 100644 --- a/tests/unit/test_export.py +++ b/tests/unit/test_export.py @@ -1,27 +1,48 @@ import io + import pytest -from molbench.molecule import Molecule, MoleculeList, Datapoint -from molbench.comparison import Comparison -from molbench.export import LatexExporter, TableExporter -from molbench.formatting import LatexFormatter -from molbench.tree import Node, DummyNode +from molbench.comparison import Comparison +from molbench.export import LatexExporter +from molbench.molecule import Datapoint, Molecule, MoleculeList +from molbench.tree import Node # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def two_data_comparison(): """Two molecules, two data_ids (ref and computed) for 'energy'.""" - mols = MoleculeList([ - Molecule("water", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(-76.0, "au")}}}), - Molecule("water", "computed", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-75.9, "au")}}}), - ]) + mols = MoleculeList( + [ + Molecule( + "water", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(-76.0, "au")}, + } + }, + ), + Molecule( + "water", + "computed", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-75.9, "au")}, + } + }, + ), + ] + ) c = Comparison() c.add(mols) return c @@ -38,6 +59,7 @@ def _export_to_string(comparison, prop, columns, rows=None, **kwargs): # Basic output # --------------------------------------------------------------------------- + def test_export_writes_nonempty(two_data_comparison): col = Node("data_id") result = _export_to_string(two_data_comparison, "energy", col) @@ -86,6 +108,7 @@ def test_export_numeric_value_in_output(two_data_comparison): # Sorting # --------------------------------------------------------------------------- + def test_export_sort_cols_default(two_data_comparison): col = Node("data_id") result = _export_to_string(two_data_comparison, "energy", col, sort_cols=True) @@ -102,20 +125,47 @@ def test_export_sort_cols_false(two_data_comparison): # Sparse row labels # --------------------------------------------------------------------------- + def test_export_sparse_row_labels_two_rows(two_data_comparison): """With two rows under the same parent, the second should have empty prefix.""" - mols = MoleculeList([ - Molecule("water", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(-76.0, "au")}}, - "gs2": {"basis": "cc-pvtz", "method": "TBE", - "data": {"energy": Datapoint(-76.1, "au")}}}), - Molecule("water", "computed", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-75.9, "au")}}, - "gs2": {"basis": "cc-pvtz", "method": "HF", - "data": {"energy": Datapoint(-76.0, "au")}}}), - ]) + mols = MoleculeList( + [ + Molecule( + "water", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(-76.0, "au")}, + }, + "gs2": { + "basis": "cc-pvtz", + "method": "TBE", + "data": {"energy": Datapoint(-76.1, "au")}, + }, + }, + ), + Molecule( + "water", + "computed", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-75.9, "au")}, + }, + "gs2": { + "basis": "cc-pvtz", + "method": "HF", + "data": {"energy": Datapoint(-76.0, "au")}, + }, + }, + ), + ] + ) c = Comparison() c.add(mols) col = Node("data_id") @@ -127,6 +177,7 @@ def test_export_sparse_row_labels_two_rows(two_data_comparison): # Key not in Comparison exits # --------------------------------------------------------------------------- + def test_export_invalid_column_key_exits(two_data_comparison): col = Node("nonexistent_key") buf = io.StringIO() @@ -138,16 +189,37 @@ def test_export_invalid_column_key_exits(two_data_comparison): # Empty field for missing data # --------------------------------------------------------------------------- + def test_export_missing_value_shows_empty_field(): """Molecule 'benzene' has energy but molecule 'water' does not for method MP2.""" - mols = MoleculeList([ - Molecule("water", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(-76.0, "au")}}}), - Molecule("benzene", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-10.0, "au")}}}), - ]) + mols = MoleculeList( + [ + Molecule( + "water", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(-76.0, "au")}, + } + }, + ), + Molecule( + "benzene", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-10.0, "au")}, + } + }, + ), + ] + ) c = Comparison() c.add(mols) col = Node("data_id") @@ -160,6 +232,7 @@ def test_export_missing_value_shows_empty_field(): # multirow # --------------------------------------------------------------------------- + def test_export_multirow_flag(two_data_comparison): col = Node("data_id") result = _export_to_string(two_data_comparison, "energy", col, multirow=True) @@ -170,19 +243,40 @@ def test_export_multirow_flag(two_data_comparison): # Silent cell collisions # --------------------------------------------------------------------------- + def test_export_uncovered_separator_collision_warns(caplog): # Two entries share the same name and data_id and differ only by # "method", but the row tree only covers "name" and the column tree # only covers "data_id" - so both values collide into the same cell. # This must be logged, not silent. - mols = MoleculeList([ - Molecule("water", "bench", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-1.0, "au")}}}), - Molecule("water", "bench", {}, - {"gs": {"basis": "cc-pvdz", "method": "MP2", - "data": {"energy": Datapoint(-2.0, "au")}}}), - ]) + mols = MoleculeList( + [ + Molecule( + "water", + "bench", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-1.0, "au")}, + } + }, + ), + Molecule( + "water", + "bench", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "MP2", + "data": {"energy": Datapoint(-2.0, "au")}, + } + }, + ), + ] + ) c = Comparison() c.add(mols) rows = Node("name") diff --git a/tests/unit/test_external_parser.py b/tests/unit/test_external_parser.py index af064a6..dc562fb 100644 --- a/tests/unit/test_external_parser.py +++ b/tests/unit/test_external_parser.py @@ -1,20 +1,26 @@ -import json -import pytest from pathlib import Path + +import pytest + from molbench.external_parser import ExternalParser from molbench.molecule import MoleculeList - # --------------------------------------------------------------------------- # Minimal mock parsers # --------------------------------------------------------------------------- + def mock_parser_1param(filepath): return ( Path(filepath).stem, {"xyz": "H 0 0 0", "charge": 0, "multiplicity": 1}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": {"value": -0.5, "unit": "au"}}}}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": {"value": -0.5, "unit": "au"}}, + } + }, ) @@ -22,8 +28,13 @@ def mock_parser_2param(filepath, name): return ( name or Path(filepath).stem, {"xyz": "H 0 0 0", "charge": 0, "multiplicity": 1}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": {"value": -0.5, "unit": "au"}}}}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": {"value": -0.5, "unit": "au"}}, + } + }, ) @@ -39,6 +50,7 @@ def bad_parser_wrong_return(filepath): # _fetch_all_outfiles # --------------------------------------------------------------------------- + def test_fetch_all_outfiles_flat(tmp_path): (tmp_path / "a.out").write_text("output") (tmp_path / "b.out").write_text("output") @@ -76,6 +88,7 @@ def test_fetch_all_outfiles_empty_dir(tmp_path): # _assignmentfile_from_outfile # --------------------------------------------------------------------------- + def test_assignmentfile_exists(tmp_path): out = tmp_path / "mol.out" ass = tmp_path / "mol.ass" @@ -98,6 +111,7 @@ def test_assignmentfile_absent(tmp_path): # load — basic # --------------------------------------------------------------------------- + def test_load_single_file(tmp_path): (tmp_path / "molA.out").write_text("fake") ml = ExternalParser().load(str(tmp_path), parser=mock_parser_1param) @@ -166,6 +180,7 @@ def forgetful_parser(filepath): # load — with assignment files # --------------------------------------------------------------------------- + def test_load_with_assignment_file(tmp_path): # Molecule states have external computation ids as transition_id. # Assignment file maps: ref_id ==> external_id (returns {external: ref}). @@ -176,12 +191,20 @@ def parser_with_tid(filepath): return ( "mol", {}, - {"s1": {"basis": "cc-pvdz", "method": "HF", + { + "s1": { + "basis": "cc-pvdz", + "method": "HF", "data": {"energy": {"value": -1.0, "unit": "au"}}, - "transition_id": "state_001"}, # external computation id - "s2": {"basis": "cc-pvdz", "method": "HF", + "transition_id": "state_001", + }, # external computation id + "s2": { + "basis": "cc-pvdz", + "method": "HF", "data": {"energy": {"value": -2.0, "unit": "au"}}, - "transition_id": "state_002"}}, # external computation id + "transition_id": "state_002", + }, + }, # external computation id ) # File format: ref_id ==> external_id; state_002 left as null → removed @@ -197,7 +220,9 @@ def parser_with_tid(filepath): def test_load_custom_out_suffix(tmp_path): (tmp_path / "mol.log").write_text("") - ml = ExternalParser().load(str(tmp_path), parser=mock_parser_1param, out_suffix=".log") + ml = ExternalParser().load( + str(tmp_path), parser=mock_parser_1param, out_suffix=".log" + ) assert len(ml) == 1 @@ -205,14 +230,23 @@ def test_load_custom_assignment_suffix(tmp_path): (tmp_path / "mol.out").write_text("") def parser_with_tid(filepath): - return ("mol", {}, - {"s1": {"basis": "b", "method": "m", - "data": {"energy": {"value": -1.0, "unit": "au"}}, - "transition_id": "ref_001"}}) # external computation id + return ( + "mol", + {}, + { + "s1": { + "basis": "b", + "method": "m", + "data": {"energy": {"value": -1.0, "unit": "au"}}, + "transition_id": "ref_001", + } + }, + ) # external computation id # File format: ref_id ==> external_id; parse returns {external: ref} # add_assignments looks up transition_id (ref_001) → assigned = "s0->s1" (tmp_path / "mol.asgn").write_text("s0->s1 ==> ref_001\n") - ml = ExternalParser().load(str(tmp_path), parser=parser_with_tid, - assignment_suffix=".asgn") + ml = ExternalParser().load( + str(tmp_path), parser=parser_with_tid, assignment_suffix=".asgn" + ) assert ml[0].state_data["s1"].get("assigned_transition_id") == "s0->s1" diff --git a/tests/unit/test_formatting.py b/tests/unit/test_formatting.py index c48dfb0..0144521 100644 --- a/tests/unit/test_formatting.py +++ b/tests/unit/test_formatting.py @@ -1,6 +1,7 @@ import pytest -from molbench.formatting import StdFormatter, LatexFormatter + from molbench.export import TableExporter +from molbench.formatting import LatexFormatter, StdFormatter class TestStdFormatter: @@ -34,7 +35,9 @@ def test_format_none_custom_empty_field(self): def test_format_non_iterable_fallback(self): # Non-numeric, non-iterable objects fall through to str() class Custom: - def __str__(self): return "custom_val" + def __str__(self): + return "custom_val" + assert self.fmt.format_datapoint(Custom()) == "custom_val" def test_custom_delimiter(self): diff --git a/tests/unit/test_functions.py b/tests/unit/test_functions.py index 1798ff3..20eaba6 100644 --- a/tests/unit/test_functions.py +++ b/tests/unit/test_functions.py @@ -1,9 +1,9 @@ import pytest + from molbench.functions import ( - substitute_template, - _substitute_single_template, default_name_template, determine_basis_cardinality, + substitute_template, walk_dict_by_key, walk_dict_values, ) @@ -15,7 +15,9 @@ def test_simple_substitution(self): assert result == ("charge=0",) def test_multiple_keys(self): - result = substitute_template("[[name]] [[basis]]", {"name": "mol", "basis": "cc-pvdz"}) + result = substitute_template( + "[[name]] [[basis]]", {"name": "mol", "basis": "cc-pvdz"} + ) assert result == ("mol cc-pvdz",) def test_no_placeholders(self): @@ -70,8 +72,7 @@ def test_list_key_with_scalar_value_not_expanded(self): # A "_list"-suffixed key whose value isn't actually a list/tuple must # not be treated as something to expand (previously crashed with a # raw IndexError since to_expand ended up empty). - result = substitute_template("charge=[[charge_list]]", - {"charge_list": 5}) + result = substitute_template("charge=[[charge_list]]", {"charge_list": 5}) assert result == ("charge=5",) def test_out_of_range_index_exits(self): @@ -116,15 +117,13 @@ def test_malformed_dunning_returns_zero(self, caplog): with caplog.at_level("ERROR", logger="molbench"): result = determine_basis_cardinality("cc-p") assert result == 0 - assert any("could not be identified" in rec.message - for rec in caplog.records) + assert any("could not be identified" in rec.message for rec in caplog.records) def test_malformed_def2_returns_zero(self, caplog): with caplog.at_level("ERROR", logger="molbench"): result = determine_basis_cardinality("def2") assert result == 0 - assert any("could not be identified" in rec.message - for rec in caplog.records) + assert any("could not be identified" in rec.message for rec in caplog.records) class TestDefaultNameTemplate: @@ -166,7 +165,7 @@ def test_deep(self): def test_full_path_returned(self): d = {"x": {"target": 99}} results = list(walk_dict_by_key(d, "target")) - keys, val = results[0] + keys, _ = results[0] assert keys == ("x", "target") def test_absent_key(self): @@ -198,7 +197,6 @@ def test_deep(self): def test_nested_dict_not_yielded_as_value(self): d = {"outer": {"inner": 5}} results = list(walk_dict_values(d)) - values = [v for _, v in results] assert isinstance(results[0][1], int) assert len(results) == 1 diff --git a/tests/unit/test_input_constructor.py b/tests/unit/test_input_constructor.py index e9a3ced..c4b85d4 100644 --- a/tests/unit/test_input_constructor.py +++ b/tests/unit/test_input_constructor.py @@ -1,15 +1,20 @@ import json -import pytest from pathlib import Path -from molbench.molecule import Molecule, MoleculeList, Datapoint -from molbench.input_constructor import TemplateConstructor, CompressedTemplateConstructor -from molbench.assignment import parse_assignment_file +import pytest + +from molbench.assignment import parse_assignment_file +from molbench.input_constructor import ( + CompressedTemplateConstructor, + TemplateConstructor, +) +from molbench.molecule import Datapoint, Molecule, MoleculeList # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _mol(name, basis="cc-pvdz", method="HF", tid=None, charge=0): state = { "basis": basis, @@ -19,9 +24,9 @@ def _mol(name, basis="cc-pvdz", method="HF", tid=None, charge=0): if tid: state["transition_id"] = tid return Molecule( - name, "bench", - {"xyz": "H 0 0 0", "charge": charge, "multiplicity": 1, - "xyz_unit": "A"}, + name, + "bench", + {"xyz": "H 0 0 0", "charge": charge, "multiplicity": 1, "xyz_unit": "A"}, {"gs": state}, ) @@ -34,6 +39,7 @@ def _bench(*names, basis="cc-pvdz"): # init_template # --------------------------------------------------------------------------- + def test_init_template_from_templates_dir(): # "pyscf_ordmp2" is a built-in template tc = TemplateConstructor("pyscf_ordmp2") @@ -56,6 +62,7 @@ def test_init_template_nonexistent_exits(): # create_inputs — files and directories # --------------------------------------------------------------------------- + def test_create_inputs_creates_directory(tmp_path, simple_template_file): tc = TemplateConstructor(simple_template_file) calc = {"method": "HF"} @@ -75,8 +82,9 @@ def test_create_inputs_default_nested_structure(tmp_path, simple_template_file): def test_create_inputs_flat_structure(tmp_path, simple_template_file): tc = TemplateConstructor(simple_template_file) - tc.create_inputs(_bench("molA", "molB"), str(tmp_path), {"method": "HF"}, - flat_structure=True) + tc.create_inputs( + _bench("molA", "molB"), str(tmp_path), {"method": "HF"}, flat_structure=True + ) # All files directly in basepath files = list(tmp_path.glob("*.in")) assert len(files) == 2 @@ -84,8 +92,9 @@ def test_create_inputs_flat_structure(tmp_path, simple_template_file): def test_create_inputs_file_count_matches_molecules(tmp_path, simple_template_file): tc = TemplateConstructor(simple_template_file) - tc.create_inputs(_bench("a", "b", "c"), str(tmp_path), {"method": "HF"}, - flat_structure=True) + tc.create_inputs( + _bench("a", "b", "c"), str(tmp_path), {"method": "HF"}, flat_structure=True + ) files = list(tmp_path.glob("*.in")) assert len(files) == 3 @@ -112,25 +121,40 @@ def test_create_inputs_list_expansion(tmp_path): f = tmp_path / "tmpl.txt" f.write_text(template) mol = Molecule( - "molA", "bench", - {"xyz_list": ["H 0 0 0", "H 0 1 0"], - "charge_list": [0, 1], - "multiplicity_list": [1, 2], - "xyz_unit": "A"}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-1.0, "au")}}}, + "molA", + "bench", + { + "xyz_list": ["H 0 0 0", "H 0 1 0"], + "charge_list": [0, 1], + "multiplicity_list": [1, 2], + "xyz_unit": "A", + }, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-1.0, "au")}, + } + }, ) tc = TemplateConstructor(str(f)) - result = tc.create_inputs(MoleculeList([mol]), str(tmp_path / "out"), - {"method": "HF"}, flat_structure=True) + result = tc.create_inputs( + MoleculeList([mol]), + str(tmp_path / "out"), + {"method": "HF"}, + flat_structure=True, + ) assert len(result) == 2 def test_create_inputs_custom_name_template(tmp_path, simple_template_file): tc = TemplateConstructor(simple_template_file) tc.create_inputs( - _bench("molA"), str(tmp_path), {"method": "HF"}, - name_template="[[name]].input", flat_structure=True + _bench("molA"), + str(tmp_path), + {"method": "HF"}, + name_template="[[name]].input", + flat_structure=True, ) assert (tmp_path / "molA.input").exists() @@ -139,6 +163,7 @@ def test_create_inputs_custom_name_template(tmp_path, simple_template_file): # create_assignments # --------------------------------------------------------------------------- + def test_create_assignments_creates_ass_files(tmp_path, simple_template_file): tc = TemplateConstructor(simple_template_file) bench = MoleculeList([_mol("molA", tid="s0->s1")]) @@ -173,14 +198,21 @@ def test_create_assignments_no_tid_warning(tmp_path, simple_template_file, capfd # should not crash; empty assignment file created -def test_create_inputs_missing_expansion_key_warns(tmp_path, simple_template_file, caplog): +def test_create_inputs_missing_expansion_key_warns( + tmp_path, simple_template_file, caplog +): # A state missing the (default) "basis" expansion key must be skipped # with a logged warning, not silently dropped. mol = Molecule( - "molA", "bench", + "molA", + "bench", {"xyz": "H 0 0 0", "charge": 0, "multiplicity": 1}, - {"gs": {"method": "HF", # no "basis" key - "data": {"energy": Datapoint(-1.0, "au")}}}, + { + "gs": { + "method": "HF", # no "basis" key + "data": {"energy": Datapoint(-1.0, "au")}, + } + }, ) tc = TemplateConstructor(simple_template_file) with caplog.at_level("WARNING", logger="molbench"): @@ -193,34 +225,43 @@ def test_create_inputs_missing_expansion_key_warns(tmp_path, simple_template_fil # CompressedTemplateConstructor # --------------------------------------------------------------------------- + def _multi_mol(name): return Molecule( - name, "bench", - {"xyz_list": ["H 0 0 0", "H 0 1 0"], - "charge_list": [0, 0], - "multiplicity_list": [1, 1], - "n_atoms_list": [1, 1]}, - {"p1": { - "basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(0.5, "au")}, - "stochiometry": [-1.0, 1.0], - }}, + name, + "bench", + { + "xyz_list": ["H 0 0 0", "H 0 1 0"], + "charge_list": [0, 0], + "multiplicity_list": [1, 1], + "n_atoms_list": [1, 1], + }, + { + "p1": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(0.5, "au")}, + "stochiometry": [-1.0, 1.0], + } + }, ) def test_compressed_creates_references_json(tmp_path, simple_template_file): tc = CompressedTemplateConstructor(simple_template_file) bench = MoleculeList([_multi_mol("relA")]) - tc.create_inputs(bench, str(tmp_path), {"method": "HF"}, - reference_path="references.json") + tc.create_inputs( + bench, str(tmp_path), {"method": "HF"}, reference_path="references.json" + ) assert (tmp_path / "references.json").exists() def test_compressed_references_json_valid(tmp_path, simple_template_file): tc = CompressedTemplateConstructor(simple_template_file) bench = MoleculeList([_multi_mol("relA")]) - tc.create_inputs(bench, str(tmp_path), {"method": "HF"}, - reference_path="references.json") + tc.create_inputs( + bench, str(tmp_path), {"method": "HF"}, reference_path="references.json" + ) refs = json.loads((tmp_path / "references.json").read_text()) assert "relA" in refs @@ -228,8 +269,9 @@ def test_compressed_references_json_valid(tmp_path, simple_template_file): def test_compressed_single_geometry_handled(tmp_path, simple_template_file): tc = CompressedTemplateConstructor(simple_template_file) bench = _bench("molA") # no xyz_list - result = tc.create_inputs(bench, str(tmp_path), {"method": "HF"}, - reference_path="references.json") + result = tc.create_inputs( + bench, str(tmp_path), {"method": "HF"}, reference_path="references.json" + ) assert len(result) >= 1 @@ -239,9 +281,13 @@ def test_compressed_property_typo_single_property_exits(tmp_path, simple_templat tc = CompressedTemplateConstructor(simple_template_file) bench = MoleculeList([_multi_mol("relA")]) with pytest.raises(SystemExit): - tc.create_inputs(bench, str(tmp_path), {"method": "HF"}, - reference_path="references.json", - compressed_property="does_not_exist") + tc.create_inputs( + bench, + str(tmp_path), + {"method": "HF"}, + reference_path="references.json", + compressed_property="does_not_exist", + ) def test_compressed_deduplicates_identical_geometries(tmp_path, simple_template_file): @@ -250,7 +296,8 @@ def test_compressed_deduplicates_identical_geometries(tmp_path, simple_template_ mol2 = _multi_mol("relB") # Both use "H 0 0 0" as first geometry → should be deduplicated bench = MoleculeList([mol1, mol2]) - result = tc.create_inputs(bench, str(tmp_path), {"method": "HF"}, - reference_path="references.json") + result = tc.create_inputs( + bench, str(tmp_path), {"method": "HF"}, reference_path="references.json" + ) # 2 unique geometries per mol, but first geometry shared → 3 unique instead of 4 assert len(result) <= 4 diff --git a/tests/unit/test_json_encoder.py b/tests/unit/test_json_encoder.py index 4ed7674..7bffd89 100644 --- a/tests/unit/test_json_encoder.py +++ b/tests/unit/test_json_encoder.py @@ -1,8 +1,10 @@ import json -import pytest + import numpy -from molbench.molecule import Molecule, Datapoint +import pytest + from molbench.json_encoder import MolbenchJSONEncoder +from molbench.molecule import Datapoint, Molecule def _dumps(obj): @@ -46,10 +48,16 @@ def test_encodes_plain_str(): def test_encodes_molecule(): mol = Molecule( - "water", "bench", + "water", + "bench", {"xyz": "O 0 0 0", "charge": 0}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-76.0, "au")}}}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-76.0, "au")}, + } + }, ) result = json.loads(_dumps(mol)) assert "water" in result @@ -72,10 +80,16 @@ def test_list_of_datapoints(): def test_encoding_molecule_does_not_mutate_system_data(): mol = Molecule( - "water", "bench", + "water", + "bench", {"xyz": "O 0 0 0", "charge": 0}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-76.0, "au")}}}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-76.0, "au")}, + } + }, ) before = dict(mol.system_data) _dumps(mol) diff --git a/tests/unit/test_molecule.py b/tests/unit/test_molecule.py index dca4568..6167f6a 100644 --- a/tests/unit/test_molecule.py +++ b/tests/unit/test_molecule.py @@ -1,11 +1,12 @@ import pytest -from molbench.molecule import Molecule, Datapoint +from molbench.molecule import Datapoint, Molecule # --------------------------------------------------------------------------- # from_benchmark # --------------------------------------------------------------------------- + def _entry_single(name=None): entry = { "charge": 0, @@ -78,7 +79,8 @@ def test_from_benchmark_data_id(): def test_from_benchmark_no_properties(): entry = { - "charge": 0, "multiplicity": 1, + "charge": 0, + "multiplicity": 1, "xyz": "H 0 0 0", } mol = Molecule.from_benchmark(entry, "bench", "mol") @@ -94,6 +96,7 @@ def test_from_benchmark_system_data_excludes_properties(): # from_external # --------------------------------------------------------------------------- + def _valid_state_data(): return { "gs": { @@ -137,8 +140,9 @@ def test_from_external_missing_data_exits(): def test_from_external_malformed_datapoint_exits(): # data dict entry must have exactly "value" and "unit" - sd = {"gs": {"method": "HF", "basis": "cc-pvdz", - "data": {"energy": {"value": 0}}}} # missing "unit" + sd = { + "gs": {"method": "HF", "basis": "cc-pvdz", "data": {"energy": {"value": 0}}} + } # missing "unit" with pytest.raises(SystemExit): Molecule.from_external({}, sd, "f.out", "mol") @@ -150,8 +154,13 @@ def test_from_external_non_dict_state_exits(): def test_from_external_non_string_state_key_exits(): - sd = {123: {"method": "HF", "basis": "cc-pvdz", - "data": {"energy": {"value": 0, "unit": "au"}}}} + sd = { + 123: { + "method": "HF", + "basis": "cc-pvdz", + "data": {"energy": {"value": 0, "unit": "au"}}, + } + } with pytest.raises(SystemExit): Molecule.from_external({}, sd, "f.out", "mol") @@ -166,15 +175,26 @@ def test_from_external_system_data_only(): # add_assignments # --------------------------------------------------------------------------- + def _mol_with_transitions(): return Molecule( - "mol", "bench", {}, + "mol", + "bench", + {}, { - "s1": {"method": "HF", "basis": "cc-pvdz", "data": {}, - "transition_id": "s0->s1"}, - "s2": {"method": "HF", "basis": "cc-pvdz", "data": {}, - "transition_id": "s0->s2"}, - } + "s1": { + "method": "HF", + "basis": "cc-pvdz", + "data": {}, + "transition_id": "s0->s1", + }, + "s2": { + "method": "HF", + "basis": "cc-pvdz", + "data": {}, + "transition_id": "s0->s2", + }, + }, ) @@ -194,8 +214,10 @@ def test_add_assignments_removes_unassigned(): def test_add_assignments_no_transition_id_skipped(): mol = Molecule( - "mol", "bench", {}, - {"gs": {"method": "HF", "basis": "cc-pvdz", "data": {}}} # no transition_id + "mol", + "bench", + {}, + {"gs": {"method": "HF", "basis": "cc-pvdz", "data": {}}}, # no transition_id ) mol.add_assignments({"some_key": "val"}) # state without transition_id is unchanged and not removed @@ -223,11 +245,12 @@ def test_add_assignments_unmatched_tid_warns(caplog): def test_add_assignments_custom_keys(): mol = Molecule( - "mol", "bench", {}, - {"s1": {"method": "HF", "basis": "cc-pvdz", "data": {}, - "my_tid": "A"}} + "mol", + "bench", + {}, + {"s1": {"method": "HF", "basis": "cc-pvdz", "data": {}, "my_tid": "A"}}, + ) + mol.add_assignments( + {"A": "B"}, old_transition_id_key="my_tid", new_transition_id_key="result_tid" ) - mol.add_assignments({"A": "B"}, - old_transition_id_key="my_tid", - new_transition_id_key="result_tid") assert mol.state_data["s1"]["result_tid"] == "B" diff --git a/tests/unit/test_molecule_list.py b/tests/unit/test_molecule_list.py index d3bea89..9191591 100644 --- a/tests/unit/test_molecule_list.py +++ b/tests/unit/test_molecule_list.py @@ -1,11 +1,20 @@ import pytest -from molbench.molecule import Molecule, MoleculeList, Datapoint +from molbench.molecule import Datapoint, Molecule, MoleculeList -def _make_mol(name, data_id="bench", charge=0, basis="cc-pvdz", method="HF", - energy=-1.0, unit="au"): + +def _make_mol( + name, + data_id="bench", + charge=0, + basis="cc-pvdz", + method="HF", + energy=-1.0, + unit="au", +): return Molecule( - name=name, data_id=data_id, + name=name, + data_id=data_id, system_data={"xyz": "H 0 0 0", "charge": charge, "multiplicity": 1}, state_data={ "gs": { @@ -20,11 +29,13 @@ def _make_mol(name, data_id="bench", charge=0, basis="cc-pvdz", method="HF", @pytest.fixture def mixed_list(): ml = MoleculeList() - ml.extend([ - _make_mol("water", charge=0, basis="cc-pvdz", method="HF", energy=-76.0), - _make_mol("benzene", charge=0, basis="cc-pvtz", method="HF", energy=-10.0), - _make_mol("methane", charge=1, basis="cc-pvdz", method="MP2", energy=-5.0), - ]) + ml.extend( + [ + _make_mol("water", charge=0, basis="cc-pvdz", method="HF", energy=-76.0), + _make_mol("benzene", charge=0, basis="cc-pvtz", method="HF", energy=-10.0), + _make_mol("methane", charge=1, basis="cc-pvdz", method="MP2", energy=-5.0), + ] + ) return ml @@ -32,6 +43,7 @@ def mixed_list(): # filter / remove # --------------------------------------------------------------------------- + def test_filter_by_name(mixed_list): result = mixed_list.filter("name", "water") assert len(result) == 1 @@ -39,10 +51,12 @@ def test_filter_by_name(mixed_list): def test_filter_by_data_id(): - ml = MoleculeList([ - _make_mol("a", data_id="bench1"), - _make_mol("b", data_id="bench2"), - ]) + ml = MoleculeList( + [ + _make_mol("a", data_id="bench1"), + _make_mol("b", data_id="bench2"), + ] + ) result = ml.filter("data_id", "bench1") assert len(result) == 1 assert result[0].name == "a" @@ -63,13 +77,21 @@ def test_filter_by_state_key_basis(mixed_list): def test_filter_removes_non_matching_states(): mol = Molecule( - "mol", "bench", {"charge": 0}, + "mol", + "bench", + {"charge": 0}, { - "s1": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(-1.0, "au")}}, - "s2": {"basis": "cc-pvtz", "method": "HF", - "data": {"energy": Datapoint(-2.0, "au")}}, - } + "s1": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(-1.0, "au")}, + }, + "s2": { + "basis": "cc-pvtz", + "method": "HF", + "data": {"energy": Datapoint(-2.0, "au")}, + }, + }, ) ml = MoleculeList([mol]) result = ml.filter("basis", "cc-pvdz") @@ -97,41 +119,63 @@ def test_filter_returns_molecule_list(mixed_list): def test_filter_by_name_and_data_id_delegate(mixed_list): - assert [m.name for m in mixed_list.filter("name", "water")] == \ - [m.name for m in mixed_list.filter_names("water")] - assert [m.name for m in mixed_list.remove("name", "water")] == \ - [m.name for m in mixed_list.remove_names("water")] - assert [m.name for m in mixed_list.filter("data_id", "bench")] == \ - [m.name for m in mixed_list.filter_data_ids("bench")] - assert [m.name for m in mixed_list.remove("data_id", "bench")] == \ - [m.name for m in mixed_list.remove_data_ids("bench")] + assert [m.name for m in mixed_list.filter("name", "water")] == [ + m.name for m in mixed_list.filter_names("water") + ] + assert [m.name for m in mixed_list.remove("name", "water")] == [ + m.name for m in mixed_list.remove_names("water") + ] + assert [m.name for m in mixed_list.filter("data_id", "bench")] == [ + m.name for m in mixed_list.filter_data_ids("bench") + ] + assert [m.name for m in mixed_list.remove("data_id", "bench")] == [ + m.name for m in mixed_list.remove_data_ids("bench") + ] # --------------------------------------------------------------------------- # filter_properties / remove_properties # --------------------------------------------------------------------------- + @pytest.fixture def property_list(): - return MoleculeList([ - Molecule( - name="multi", data_id="bench", system_data={"charge": 0}, - state_data={ - "s1": {"basis": "cc-pvdz", "method": "adc2", - "data": {"excitation_energy": Datapoint(1.0, "eV"), - "oscillator_strength": Datapoint(0.1, "au")}}, - "s2": {"basis": "cc-pvdz", "method": "adc2", - "data": {"oscillator_strength": Datapoint(0.2, "au")}}, - } - ), - Molecule( - name="osc_only", data_id="bench", system_data={"charge": 0}, - state_data={ - "s1": {"basis": "cc-pvdz", "method": "adc2", - "data": {"oscillator_strength": Datapoint(0.3, "au")}}, - } - ), - ]) + return MoleculeList( + [ + Molecule( + name="multi", + data_id="bench", + system_data={"charge": 0}, + state_data={ + "s1": { + "basis": "cc-pvdz", + "method": "adc2", + "data": { + "excitation_energy": Datapoint(1.0, "eV"), + "oscillator_strength": Datapoint(0.1, "au"), + }, + }, + "s2": { + "basis": "cc-pvdz", + "method": "adc2", + "data": {"oscillator_strength": Datapoint(0.2, "au")}, + }, + }, + ), + Molecule( + name="osc_only", + data_id="bench", + system_data={"charge": 0}, + state_data={ + "s1": { + "basis": "cc-pvdz", + "method": "adc2", + "data": {"oscillator_strength": Datapoint(0.3, "au")}, + }, + }, + ), + ] + ) def test_filter_properties(property_list): @@ -145,12 +189,13 @@ def test_filter_properties(property_list): # the remaining state keeps its other entries assert result[0].state_data["s1"]["method"] == "adc2" # Ensure that nothing is dropped - result = property_list.filter_properties("excitation_energy", - "oscillator_strength") + result = property_list.filter_properties("excitation_energy", "oscillator_strength") assert len(result) == 2 assert set(result[0].state_data) == {"s1", "s2"} - assert set(result[0].state_data["s1"]["data"]) == \ - {"excitation_energy", "oscillator_strength"} + assert set(result[0].state_data["s1"]["data"]) == { + "excitation_energy", + "oscillator_strength", + } assert list(result[1].state_data["s1"]["data"]) == ["oscillator_strength"] # ensure that multi looses its energy removed = property_list.remove_properties("excitation_energy") @@ -166,15 +211,18 @@ def test_filter_properties(property_list): for ptype in data["data"] ) # ensure that everything is dropped - result = property_list.remove_properties("excitation_energy", - "oscillator_strength") + result = property_list.remove_properties("excitation_energy", "oscillator_strength") assert len(result) == 0 # ensure that a state without data is dropped no_data = MoleculeList() - no_data.append(Molecule( - name="no_data", data_id="test", system_data={}, - state_data={"s1": {"method": "adc2", "basis": "cc-pvdz"}} - )) + no_data.append( + Molecule( + name="no_data", + data_id="test", + system_data={}, + state_data={"s1": {"method": "adc2", "basis": "cc-pvdz"}}, + ) + ) res = no_data.filter_properties("bla") assert len(res) == 0 res = no_data.remove_properties("bla") @@ -185,6 +233,7 @@ def test_filter_properties(property_list): # filter_by_range # --------------------------------------------------------------------------- + def test_filter_by_range_both(mixed_list): result = mixed_list.filter_by_range("charge", min=0, max=0) assert all(m.system_data["charge"] == 0 for m in result) @@ -209,12 +258,13 @@ def test_filter_by_range_none_none(mixed_list): # filter_by_vec_norm # --------------------------------------------------------------------------- + def _mol_with_vec_norm(name, vec): return Molecule( - name, "bench", + name, + "bench", {"vec_norm": vec}, - {"gs": {"basis": "b", "method": "m", - "data": {"e": Datapoint(0.0, "au")}}} + {"gs": {"basis": "b", "method": "m", "data": {"e": Datapoint(0.0, "au")}}}, ) @@ -238,9 +288,10 @@ def test_filter_by_vec_norm_scalar_promoted(): def test_filter_by_vec_norm_dict_value(): mol = Molecule( - "m", "b", {"vec_norm": {"x": 0.5, "y": 0.3}}, - {"gs": {"basis": "b", "method": "m", - "data": {"e": Datapoint(0.0, "au")}}} + "m", + "b", + {"vec_norm": {"x": 0.5, "y": 0.3}}, + {"gs": {"basis": "b", "method": "m", "data": {"e": Datapoint(0.0, "au")}}}, ) result = MoleculeList([mol]).filter_by_vec_norm( "vec_norm", min=[0.0, 0.0], max=[1.0, 1.0] @@ -258,6 +309,7 @@ def test_filter_by_vec_norm_none_none(): # apply_stochiometry # --------------------------------------------------------------------------- + def test_apply_stochiometry_basic(): mol_a = _make_mol("molA", energy=-10.0) mol_b = _make_mol("molB", energy=-5.0) diff --git a/tests/unit/test_statistics.py b/tests/unit/test_statistics.py index 8fd0cef..9ae52fb 100644 --- a/tests/unit/test_statistics.py +++ b/tests/unit/test_statistics.py @@ -1,13 +1,23 @@ -import pytest import numpy as np -from molbench.molecule import Molecule, MoleculeList, Datapoint +import pytest + from molbench.comparison import Comparison +from molbench.molecule import Datapoint, Molecule, MoleculeList from molbench.statistics import ( - Statistics, register_as_error_measure, - mse, mae, sde, rmsd, min as stat_min, max as stat_max, median_se, - _collect_errors, + Statistics, + mae, + median_se, + mse, + register_as_error_measure, + rmsd, + sde, +) +from molbench.statistics import ( + max as stat_max, +) +from molbench.statistics import ( + min as stat_min, ) - INTEREST = {"method": "HF"} REFERENCE = {"method": "TBE"} @@ -18,6 +28,7 @@ # Helpers # --------------------------------------------------------------------------- + def _get_errors(comparison, interest=INTEREST, reference=REFERENCE): return Statistics(comparison).compare(interest, reference) @@ -30,6 +41,7 @@ def _assign(proptype=PROPTYPE): # identify # --------------------------------------------------------------------------- + def test_identify_reference(known_comparison): stats = Statistics(known_comparison) ident = stats.identify(INTEREST, REFERENCE) @@ -56,26 +68,36 @@ def test_identify_neither(known_comparison): # compare — absolute errors # --------------------------------------------------------------------------- + def test_compare_absolute_error(known_comparison): errors = _get_errors(known_comparison) assert len(errors) == 1 - for ref_keys, interest_dict in errors.items(): - for int_keys, se in interest_dict.items(): + for interest_dict in errors.values(): + for se in interest_dict.values(): assert se.value == pytest.approx(0.1) def test_compare_signed_positive(known_comparison): # interest (-75.9) - reference (-76.0) = +0.1 errors = _get_errors(known_comparison) - for _, id_ in errors.items(): - for _, se in id_.items(): + for id_ in errors.values(): + for se in id_.values(): assert se.value > 0 def test_compare_empty_interest(): - ref = Molecule("water", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(-76.0, "au")}}}) + ref = Molecule( + "water", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(-76.0, "au")}, + } + }, + ) c = Comparison() c.add(MoleculeList([ref])) errors = Statistics(c).compare({"method": "HF"}, {"method": "TBE"}) @@ -86,12 +108,11 @@ def test_compare_empty_interest(): # compare — relative errors # --------------------------------------------------------------------------- + def test_compare_relative_error(known_comparison): - errors = Statistics(known_comparison).compare( - INTEREST, REFERENCE, relative=True - ) - for _, id_ in errors.items(): - for _, se in id_.items(): + errors = Statistics(known_comparison).compare(INTEREST, REFERENCE, relative=True) + for id_ in errors.values(): + for se in id_.values(): # (−75.9 − (−76.0)) / |−76.0| = 0.1/76.0 assert se.value == pytest.approx(0.1 / 76.0, rel=1e-5) @@ -100,8 +121,8 @@ def test_compare_relative_with_damping(known_comparison): errors = Statistics(known_comparison).compare( INTEREST, REFERENCE, relative=True, relative_damping=1.0 ) - for _, id_ in errors.items(): - for _, se in id_.items(): + for id_ in errors.values(): + for se in id_.values(): # (−75.9 − (−76.0)) / (|−76.0| + 1.0) = 0.1/77.0 assert se.value == pytest.approx(0.1 / 77.0, rel=1e-5) @@ -110,6 +131,7 @@ def test_compare_relative_with_damping(known_comparison): # evaluate # --------------------------------------------------------------------------- + def test_evaluate_mse(known_comparison): errors = _get_errors(known_comparison) result = Statistics(known_comparison).evaluate(errors, "mse", proptype=PROPTYPE) @@ -121,21 +143,21 @@ def test_evaluate_mse(known_comparison): def test_evaluate_mae(known_comparison): errors = _get_errors(known_comparison) result = Statistics(known_comparison).evaluate(errors, "mae", proptype=PROPTYPE) - val, count = result["mae"] + val, _ = result["mae"] assert val == pytest.approx(0.1) def test_evaluate_rmsd(known_comparison): errors = _get_errors(known_comparison) result = Statistics(known_comparison).evaluate(errors, "rmsd", proptype=PROPTYPE) - val, count = result["rmsd"] + val, _ = result["rmsd"] assert val == pytest.approx(0.1) def test_evaluate_sde_single_point(known_comparison): errors = _get_errors(known_comparison) result = Statistics(known_comparison).evaluate(errors, "sde", proptype=PROPTYPE) - val, count = result["sde"] + val, _ = result["sde"] # std of a single value is 0 assert val == pytest.approx(0.0) @@ -182,6 +204,7 @@ def test_evaluate_no_assign_no_proptype_returns_none(known_comparison): # two-molecule: known MSE, MAE, RMSD # --------------------------------------------------------------------------- + def test_mse_two_molecules(two_molecule_comparison): errors = _get_errors(two_molecule_comparison) result = Statistics(two_molecule_comparison).evaluate( @@ -197,7 +220,7 @@ def test_mae_two_molecules(two_molecule_comparison): result = Statistics(two_molecule_comparison).evaluate( errors, "mae", proptype=PROPTYPE ) - val, count = result["mae"] + val, _ = result["mae"] assert val == pytest.approx(0.2) @@ -224,6 +247,7 @@ def test_min_max_two_molecules(two_molecule_comparison): # mae on empty errors # --------------------------------------------------------------------------- + def test_mae_empty_errors(known_comparison): assign = _assign() val, count = mae({}, assign) @@ -235,6 +259,7 @@ def test_mae_empty_errors(known_comparison): # register_as_error_measure decorator # --------------------------------------------------------------------------- + def test_register_as_error_measure(): @register_as_error_measure def my_custom_measure(signed_errors, assign): @@ -249,6 +274,7 @@ def my_custom_measure(signed_errors, assign): # assign_by_proptype # --------------------------------------------------------------------------- + def test_assign_by_proptype_matches(): assign = Statistics.assign_by_proptype("energy") ref_keys = ("water", "cc-pvdz", "TBE", "energy", "ref") @@ -274,6 +300,7 @@ def test_assign_by_proptype_different_ref_int(): # __init__ type guard # --------------------------------------------------------------------------- + def test_init_wrong_type_exits_cleanly(): # Must hit the intended log.critical() path immediately, not log a # non-fatal error and then crash later on an unrelated AttributeError. @@ -285,13 +312,32 @@ def test_init_wrong_type_exits_cleanly(): # relative error with a zero reference value # --------------------------------------------------------------------------- + def test_compare_relative_zero_reference_skips_pair(caplog): - ref = Molecule("water", "ref", {}, - {"gs": {"basis": "cc-pvdz", "method": "TBE", - "data": {"energy": Datapoint(0.0, "au")}}}) - interest = Molecule("water", "computed", {}, - {"gs": {"basis": "cc-pvdz", "method": "HF", - "data": {"energy": Datapoint(1.0, "au")}}}) + ref = Molecule( + "water", + "ref", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "TBE", + "data": {"energy": Datapoint(0.0, "au")}, + } + }, + ) + interest = Molecule( + "water", + "computed", + {}, + { + "gs": { + "basis": "cc-pvdz", + "method": "HF", + "data": {"energy": Datapoint(1.0, "au")}, + } + }, + ) c = Comparison() c.add(MoleculeList([ref, interest])) with caplog.at_level("WARNING", logger="molbench"): @@ -304,6 +350,7 @@ def test_compare_relative_zero_reference_skips_pair(caplog): # extreme_error_keys # --------------------------------------------------------------------------- + def test_extreme_error_keys_two_molecules(two_molecule_comparison): errors = _get_errors(two_molecule_comparison) result = Statistics(two_molecule_comparison).extreme_error_keys( @@ -316,9 +363,7 @@ def test_extreme_error_keys_two_molecules(two_molecule_comparison): def test_extreme_error_keys_empty_returns_empty_dict(known_comparison): - result = Statistics(known_comparison).extreme_error_keys( - {}, proptype=PROPTYPE - ) + result = Statistics(known_comparison).extreme_error_keys({}, proptype=PROPTYPE) assert result == {} @@ -332,6 +377,7 @@ def test_extreme_error_keys_no_assign_no_proptype_logs_error(known_comparison): # empty-input behavior of the built-in error measures # --------------------------------------------------------------------------- + def test_empty_errors_all_measures_no_crash(): assign = _assign() for measure in (mse, sde, stat_min, stat_max, median_se, rmsd): diff --git a/tests/unit/test_tree.py b/tests/unit/test_tree.py index cda7d88..2fe53cd 100644 --- a/tests/unit/test_tree.py +++ b/tests/unit/test_tree.py @@ -1,5 +1,4 @@ -import pytest -from molbench.tree import Node, DummyNode +from molbench.tree import DummyNode, Node def test_node_construction_links_parent(): @@ -55,7 +54,7 @@ def test_traverse_generations_two_levels(): gens = list(root.traverse_generations()) assert len(gens) == 3 assert tuple(n.value for n in gens[0]) == ("root",) - assert set(n.value for n in gens[1]) == {"a", "b"} + assert {n.value for n in gens[1]} == {"a", "b"} assert tuple(n.value for n in gens[2]) == ("a1",) @@ -73,10 +72,10 @@ def test_walk_leaves_only_terminal(): root = Node("root") a = Node("a", root) b = Node("b", root) - leaf_a = Node("a1", a) - leaf_b = Node("b1", b) + Node("a1", a) + Node("b1", b) leaves = list(root.walk_leaves()) - assert set(n.value for n in leaves) == {"a1", "b1"} + assert {n.value for n in leaves} == {"a1", "b1"} assert a not in leaves assert root not in leaves