From decc2481aae086449d1cdfdbc58023950b17413a Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" <64498081+galjos@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:24:02 +0200 Subject: [PATCH 1/2] Rank screen results by Gibbs free energy and report failures Add rank_by_gibbs(results) returning the successful records sorted by absolute Gibbs free energy (most stable first), exported from ThermoScreening.thermo. The thermo screen CLI now prints this ranking and lists any failed jobs with their error message. Closes #92 --- ThermoScreening/cli/thermo.py | 19 +++++++++++++++---- ThermoScreening/thermo/__init__.py | 2 +- ThermoScreening/thermo/screening.py | 24 ++++++++++++++++++++++++ docs/api.rst | 1 + tests/thermo/test_screening.py | 25 +++++++++++++++++++++++-- 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/ThermoScreening/cli/thermo.py b/ThermoScreening/cli/thermo.py index 18aa2ca..2988b26 100644 --- a/ThermoScreening/cli/thermo.py +++ b/ThermoScreening/cli/thermo.py @@ -12,7 +12,7 @@ install_slakos, ) from ThermoScreening.thermo.api import execute -from ThermoScreening.thermo.screening import screen +from ThermoScreening.thermo.screening import screen, rank_by_gibbs from ThermoScreening.thermo.conformers import generate as generate_conformers, write_conformers from ThermoScreening.version import __version__ @@ -245,11 +245,22 @@ def run_screen(parser_args): resume=parser_args.resume, ) - failed = sum(1 for record in results if record["status"] != "ok") - print(f"Screened {len(results)} molecules ({failed} failed).") + ranked = rank_by_gibbs(results) + if ranked: + print("Ranked by Gibbs free energy (most stable first):") + for position, record in enumerate(ranked, start=1): + print(f" {position}. {record['name']} G = {record['G_total_hartree']:.6f} Ha") + + failures = [record for record in results if record["status"] != "ok"] + if failures: + print(f"Failed ({len(failures)}):") + for record in failures: + print(f" {record['name']}: {record['error']}") + + print(f"Screened {len(results)} molecules ({len(failures)} failed).") print(f"Results: {parser_args.out}.csv, {parser_args.out}.json") - return 1 if failed else 0 + return 1 if failures else 0 def run_conformers(parser_args): diff --git a/ThermoScreening/thermo/__init__.py b/ThermoScreening/thermo/__init__.py index 9537ad8..8c0af7d 100644 --- a/ThermoScreening/thermo/__init__.py +++ b/ThermoScreening/thermo/__init__.py @@ -6,7 +6,7 @@ from .inputFileReader import InputFileReader from .system import System from .thermo import Thermo -from .screening import screen +from .screening import screen, rank_by_gibbs from .conformers import generate as generate_conformers, write_conformers from .reactions import reaction_free_energy, reduction_potential from .ensemble import boltzmann_weights, ensemble_free_energy, lowest_gibbs diff --git a/ThermoScreening/thermo/screening.py b/ThermoScreening/thermo/screening.py index 96e958f..49a2481 100644 --- a/ThermoScreening/thermo/screening.py +++ b/ThermoScreening/thermo/screening.py @@ -115,6 +115,30 @@ def _thermo_summary(thermo): } +def rank_by_gibbs(results): + """ + Return the successful screen records sorted by Gibbs free energy. + + Parameters + ---------- + results : list of dict + Records as returned by :func:`screen`. + + Returns + ------- + list of dict + The records whose ``status`` is ``"ok"`` (and that carry a + ``G_total_hartree``), sorted by absolute Gibbs free energy ascending -- + i.e. the most stable molecule first. Failed records are omitted. + """ + ranked = [ + record + for record in results + if record.get("status") == "ok" and record.get("G_total_hartree") is not None + ] + return sorted(ranked, key=lambda record: record["G_total_hartree"]) + + def _load_completed(out): """ Load the successfully-completed records from a prior ``.json``. diff --git a/docs/api.rst b/docs/api.rst index 0e187f4..2bdd9a8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -14,6 +14,7 @@ Screening --------- .. autofunction:: ThermoScreening.thermo.screening.screen +.. autofunction:: ThermoScreening.thermo.screening.rank_by_gibbs .. autoclass:: ThermoScreening.thermo.screening.ScreeningJob :members: diff --git a/tests/thermo/test_screening.py b/tests/thermo/test_screening.py index 4f033f5..f3d7387 100644 --- a/tests/thermo/test_screening.py +++ b/tests/thermo/test_screening.py @@ -433,11 +433,28 @@ def test_cli_parse_args_routes_screen(): assert cli.parse_args(["screen", "molecules.csv", "--resume"]).resume is True -def test_cli_run_screen_returns_failure_count(monkeypatch): +def test_rank_by_gibbs_sorts_and_filters(): + from ThermoScreening.thermo.screening import rank_by_gibbs + + results = [ + {"name": "a", "status": "ok", "G_total_hartree": -1.0}, + {"name": "b", "status": "ok", "G_total_hartree": -3.0}, + {"name": "c", "status": "error", "error": "boom"}, + {"name": "d", "status": "ok", "G_total_hartree": -2.0}, + {"name": "e", "status": "ok"}, # ok but no Gibbs value -> dropped + ] + ranked = rank_by_gibbs(results) + assert [record["name"] for record in ranked] == ["b", "d", "a"] + + +def test_cli_run_screen_returns_failure_count(monkeypatch, capsys): import ThermoScreening.cli.thermo as cli monkeypatch.setattr( - cli, "screen", lambda *args, **kwargs: [{"status": "ok"}, {"status": "error"}] + cli, "screen", lambda *args, **kwargs: [ + {"name": "m1", "status": "ok", "G_total_hartree": -2.0}, + {"name": "m2", "status": "error", "error": "boom"}, + ] ) args = Namespace( @@ -448,6 +465,10 @@ def test_cli_run_screen_returns_failure_count(monkeypatch): ) assert cli.run_screen(args) == 1 # one molecule failed + out = capsys.readouterr().out + assert "Ranked by Gibbs free energy" in out + assert "1. m1" in out + assert "m2: boom" in out def test_cli_parse_args_routes_conformers(): From 3cad5642cd3bd4c41ed054730de295af913a4a68 Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" <64498081+galjos@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:26:22 +0200 Subject: [PATCH 2/2] Clarify rank_by_gibbs docstring and cover the all-ok path --- ThermoScreening/thermo/screening.py | 2 +- tests/thermo/test_screening.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ThermoScreening/thermo/screening.py b/ThermoScreening/thermo/screening.py index 49a2481..4747da9 100644 --- a/ThermoScreening/thermo/screening.py +++ b/ThermoScreening/thermo/screening.py @@ -128,7 +128,7 @@ def rank_by_gibbs(results): ------- list of dict The records whose ``status`` is ``"ok"`` (and that carry a - ``G_total_hartree``), sorted by absolute Gibbs free energy ascending -- + ``G_total_hartree``), sorted by total Gibbs free energy ascending -- i.e. the most stable molecule first. Failed records are omitted. """ ranked = [ diff --git a/tests/thermo/test_screening.py b/tests/thermo/test_screening.py index f3d7387..9b5bcb4 100644 --- a/tests/thermo/test_screening.py +++ b/tests/thermo/test_screening.py @@ -471,6 +471,28 @@ def test_cli_run_screen_returns_failure_count(monkeypatch, capsys): assert "m2: boom" in out +def test_cli_run_screen_all_ok_returns_zero(monkeypatch, capsys): + import ThermoScreening.cli.thermo as cli + + monkeypatch.setattr( + cli, "screen", lambda *args, **kwargs: [ + {"name": "m1", "status": "ok", "G_total_hartree": -2.0}, + {"name": "m2", "status": "ok", "G_total_hartree": -3.0}, + ] + ) + args = Namespace( + source="x", out="res", charge=0.0, temperature=298.15, + pressure=101325.0, directory="screening", parameter_set="3ob", + solvent=None, dispersion=None, quasi_rrho=False, engine="dftb+", + method="GFN2-xTB", resume=False, + ) + + assert cli.run_screen(args) == 0 # no failures + out = capsys.readouterr().out + assert "1. m2" in out and "2. m1" in out # most stable (m2) first + assert "Failed" not in out + + def test_cli_parse_args_routes_conformers(): import ThermoScreening.cli.thermo as cli