From cdb137976c573a3bf3765c0ce7df906eb499089c Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 27 Jul 2026 11:14:30 -0700 Subject: [PATCH 1/5] update: read homo/lumo from converged values --- express/parsers/apps/nwchem/formats/txt.py | 70 +++++++++++++++++-- express/parsers/apps/nwchem/settings.py | 12 +++- tests/fixtures/nwchem/references.py | 16 ++++- .../parsers/apps/nwchem/test_parser.py | 18 ++++- tests/manifest.yaml | 8 +++ 5 files changed, 114 insertions(+), 10 deletions(-) diff --git a/express/parsers/apps/nwchem/formats/txt.py b/express/parsers/apps/nwchem/formats/txt.py index c2886eaa..a41ac585 100644 --- a/express/parsers/apps/nwchem/formats/txt.py +++ b/express/parsers/apps/nwchem/formats/txt.py @@ -3,6 +3,20 @@ from express.parsers.formats.txt import BaseTXTParser +def _fortran_float(value): + """ + Converts a Fortran-formatted float string (e.g. "-1.234D+01", double-precision + "D" exponent notation) to a Python float. + + Args: + value (str): Fortran-formatted number, e.g. "-1.234D+01". + + Returns: + float + """ + return float(value.replace("D", "E").replace("d", "e")) + + class NwchemTXTParser(BaseTXTParser): """ Nwchem text parser class. @@ -11,6 +25,54 @@ class NwchemTXTParser(BaseTXTParser): def __init__(self, work_dir): super(NwchemTXTParser, self).__init__(work_dir) + def _converged_orbital_block(self, text): + """ + Returns the text of the final molecular orbital analysis section, or an empty + string if it is not present. + + Args: + text (str): text to extract data from. + + Returns: + str + """ + start_index = text.rfind(settings.FRONTIER_ORBITAL_BLOCK_START_FLAG) + return text[start_index:] if start_index != -1 else "" + + def _converged_homo_energy(self, text): + """ + Extracts the HOMO energy (Hartree): the highest energy among occupied orbitals. + + Args: + text (str): text to extract data from. + + Returns: + float | None + """ + occupied_energies = [ + _fortran_float(orbital.group("energy")) + for orbital in settings.VECTOR_REGEX.finditer(self._converged_orbital_block(text)) + if _fortran_float(orbital.group("occupation")) > 0 + ] + return max(occupied_energies) if occupied_energies else None + + def _converged_lumo_energy(self, text): + """ + Extracts the LUMO energy (Hartree): the lowest energy among unoccupied orbitals. + + Args: + text (str): text to extract data from. + + Returns: + float | None + """ + virtual_energies = [ + _fortran_float(orbital.group("energy")) + for orbital in settings.VECTOR_REGEX.finditer(self._converged_orbital_block(text)) + if _fortran_float(orbital.group("occupation")) == 0 + ] + return min(virtual_energies) if virtual_energies else None + def total_energy(self, text): """ Extracts total energy. @@ -42,7 +104,7 @@ def total_energy_contributions(self, text): def homo_energy(self, text): """ - Extracts HOMO energy. + Extracts the converged HOMO energy. Args: text (str): text to extract data from. @@ -50,11 +112,11 @@ def homo_energy(self, text): Returns: float | None """ - return self._general_output_parser(text, **settings.REGEX["homo_energy"]) + return self._converged_homo_energy(text) def lumo_energy(self, text): """ - Extracts LUMO energy. + Extracts the converged LUMO energy. Args: text (str): text to extract data from. @@ -62,7 +124,7 @@ def lumo_energy(self, text): Returns: float | None """ - return self._general_output_parser(text, **settings.REGEX["lumo_energy"]) + return self._converged_lumo_energy(text) def zero_point_energy(self, text): """ diff --git a/express/parsers/apps/nwchem/settings.py b/express/parsers/apps/nwchem/settings.py index 9494ca6d..f1beb0c1 100644 --- a/express/parsers/apps/nwchem/settings.py +++ b/express/parsers/apps/nwchem/settings.py @@ -1,13 +1,21 @@ +import re + from express.parsers.settings import GENERAL_REGEX COMMON_REGEX = r"{}\s+[=:<>]\s*([-+]?\d*\.?\d*([Ee][+-]?\d+)?)" DOUBLE_REGEX = GENERAL_REGEX["double_number"] NWCHEM_OUTPUT_FILE_REGEX = "Northwest Computational Chemistry Package" +# Header of the final molecular orbital analysis section; VECTOR_REGEX matches its orbital lines. +FRONTIER_ORBITAL_BLOCK_START_FLAG = "DFT Final Molecular Orbital Analysis" +VECTOR_REGEX = re.compile( + r"Vector\s+\d+\s+Occ=\s*(?P[\dDEe.+-]+)\s+E=\s*(?P[\dDEe.+-]+)" +) + REGEX = { "total_energy": {"regex": COMMON_REGEX.format("Total DFT energy"), "occurrences": -1, "output_type": "float"}, - "homo_energy": {"regex": COMMON_REGEX.format("HOMO"), "occurrences": -1, "output_type": "float"}, - "lumo_energy": {"regex": COMMON_REGEX.format("LUMO"), "occurrences": -1, "output_type": "float"}, + # homo_energy / lumo_energy are parsed from the orbital analysis section instead; + # see NwchemTXTParser._converged_homo_energy / _converged_lumo_energy. "zero_point_energy": { "regex": COMMON_REGEX.format("Zero-Point correction to Energy"), "occurrences": -1, diff --git a/tests/fixtures/nwchem/references.py b/tests/fixtures/nwchem/references.py index 1db25356..5f6537c8 100644 --- a/tests/fixtures/nwchem/references.py +++ b/tests/fixtures/nwchem/references.py @@ -4,8 +4,20 @@ All reference energies are in eV. """ TOTAL_ENERGY = -2079.18666382721904 -HOMO_ENERGY = -12.800485418916242 -LUMO_ENERGY = 3.1242763921882197 +# HOMO/LUMO from the final orbital analysis section. The *_INITIAL_GUESS values come from the +# initial-guess section and are used by the tests to check the parser does not return them. +HOMO_ENERGY = -7.938587261191046 +LUMO_ENERGY = 1.793148251055798 +HOMO_ENERGY_INITIAL_GUESS = -12.800485418916242 +LUMO_ENERGY_INITIAL_GUESS = 3.1242763921882197 + +# From test-002/nwchem-frequency.log, whose output contains several orbital analysis sections +# (one per geometry-optimization step); the parser must read the last one. +HOMO_ENERGY_MULTISTEP = -7.8966272890902385 +LUMO_ENERGY_MULTISTEP = 1.7759215328081597 +HOMO_ENERGY_MULTISTEP_INITIAL_GUESS = -10.187320671325603 +LUMO_ENERGY_MULTISTEP_INITIAL_GUESS = -3.5424611206222094 + ZERO_POINT_ENERGY = 0.5748347036575007 THERMAL_CORRECTION_TO_ENERGY = 15.033 THERMAL_CORRECTION_TO_ENTHALPY = 15.626 diff --git a/tests/integration/parsers/apps/nwchem/test_parser.py b/tests/integration/parsers/apps/nwchem/test_parser.py index 269a47a7..e05eaa2f 100644 --- a/tests/integration/parsers/apps/nwchem/test_parser.py +++ b/tests/integration/parsers/apps/nwchem/test_parser.py @@ -16,10 +16,24 @@ def test_nwchem_total_energy(self): self.assertAlmostEqual(self.parser.total_energy(), TOTAL_ENERGY, places=2) def test_nwchem_homo_energy(self): - self.assertAlmostEqual(self.parser.homo_energy(), HOMO_ENERGY, places=2) + homo_energy = self.parser.homo_energy() + self.assertAlmostEqual(homo_energy, HOMO_ENERGY, places=2) + self.assertNotAlmostEqual(homo_energy, HOMO_ENERGY_INITIAL_GUESS, places=2) def test_nwchem_lumo_energy(self): - self.assertAlmostEqual(self.parser.lumo_energy(), LUMO_ENERGY, places=2) + lumo_energy = self.parser.lumo_energy() + self.assertAlmostEqual(lumo_energy, LUMO_ENERGY, places=2) + self.assertNotAlmostEqual(lumo_energy, LUMO_ENERGY_INITIAL_GUESS, places=2) + + def test_nwchem_homo_energy_multistep(self): + homo_energy = self.parser.homo_energy() + self.assertAlmostEqual(homo_energy, HOMO_ENERGY_MULTISTEP, places=2) + self.assertNotAlmostEqual(homo_energy, HOMO_ENERGY_MULTISTEP_INITIAL_GUESS, places=2) + + def test_nwchem_lumo_energy_multistep(self): + lumo_energy = self.parser.lumo_energy() + self.assertAlmostEqual(lumo_energy, LUMO_ENERGY_MULTISTEP, places=2) + self.assertNotAlmostEqual(lumo_energy, LUMO_ENERGY_MULTISTEP_INITIAL_GUESS, places=2) def test_nwchem_total_energy_contributions(self): self.assertDeepAlmostEqual(self.parser.total_energy_contributions(), TOTAL_ENERGY_CONTRIBUTION, places=2) diff --git a/tests/manifest.yaml b/tests/manifest.yaml index f0bb42f2..e015095e 100644 --- a/tests/manifest.yaml +++ b/tests/manifest.yaml @@ -10,6 +10,14 @@ test_nwchem_lumo_energy: workDir: fixtures/nwchem/test-001 stdoutFile: fixtures/nwchem/test-001/nwchem-total-energy.log +test_nwchem_homo_energy_multistep: + workDir: fixtures/nwchem/test-002 + stdoutFile: fixtures/nwchem/test-002/nwchem-frequency.log + +test_nwchem_lumo_energy_multistep: + workDir: fixtures/nwchem/test-002 + stdoutFile: fixtures/nwchem/test-002/nwchem-frequency.log + test_nwchem_total_energy_contributions: workDir: fixtures/nwchem/test-001 stdoutFile: fixtures/nwchem/test-001/nwchem-total-energy.log From 162f111be4e837d5400d9a36622339df16b20dd8 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 30 Jul 2026 20:30:44 -0700 Subject: [PATCH 2/5] update: optimize (opus 5) --- express/parsers/apps/nwchem/formats/txt.py | 37 +++++++--------------- express/parsers/apps/nwchem/settings.py | 9 ++++-- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/express/parsers/apps/nwchem/formats/txt.py b/express/parsers/apps/nwchem/formats/txt.py index a41ac585..217d7609 100644 --- a/express/parsers/apps/nwchem/formats/txt.py +++ b/express/parsers/apps/nwchem/formats/txt.py @@ -39,39 +39,24 @@ def _converged_orbital_block(self, text): start_index = text.rfind(settings.FRONTIER_ORBITAL_BLOCK_START_FLAG) return text[start_index:] if start_index != -1 else "" - def _converged_homo_energy(self, text): + def _frontier_orbital_energy(self, text, occupied, select): """ - Extracts the HOMO energy (Hartree): the highest energy among occupied orbitals. + Extracts a frontier orbital energy (Hartree) from the final orbital analysis section. Args: text (str): text to extract data from. + occupied (bool): select among occupied orbitals if True, unoccupied ones if False. + select (callable): reduces the matching energies to the frontier one (max or min). Returns: float | None """ - occupied_energies = [ + energies = [ _fortran_float(orbital.group("energy")) for orbital in settings.VECTOR_REGEX.finditer(self._converged_orbital_block(text)) - if _fortran_float(orbital.group("occupation")) > 0 + if (_fortran_float(orbital.group("occupation")) > 0) == occupied ] - return max(occupied_energies) if occupied_energies else None - - def _converged_lumo_energy(self, text): - """ - Extracts the LUMO energy (Hartree): the lowest energy among unoccupied orbitals. - - Args: - text (str): text to extract data from. - - Returns: - float | None - """ - virtual_energies = [ - _fortran_float(orbital.group("energy")) - for orbital in settings.VECTOR_REGEX.finditer(self._converged_orbital_block(text)) - if _fortran_float(orbital.group("occupation")) == 0 - ] - return min(virtual_energies) if virtual_energies else None + return select(energies) if energies else None def total_energy(self, text): """ @@ -104,7 +89,7 @@ def total_energy_contributions(self, text): def homo_energy(self, text): """ - Extracts the converged HOMO energy. + Extracts the HOMO energy. Args: text (str): text to extract data from. @@ -112,11 +97,11 @@ def homo_energy(self, text): Returns: float | None """ - return self._converged_homo_energy(text) + return self._frontier_orbital_energy(text, **settings.FRONTIER_ORBITAL_ENERGY["homo_energy"]) def lumo_energy(self, text): """ - Extracts the converged LUMO energy. + Extracts the LUMO energy. Args: text (str): text to extract data from. @@ -124,7 +109,7 @@ def lumo_energy(self, text): Returns: float | None """ - return self._converged_lumo_energy(text) + return self._frontier_orbital_energy(text, **settings.FRONTIER_ORBITAL_ENERGY["lumo_energy"]) def zero_point_energy(self, text): """ diff --git a/express/parsers/apps/nwchem/settings.py b/express/parsers/apps/nwchem/settings.py index f1beb0c1..b0b72b53 100644 --- a/express/parsers/apps/nwchem/settings.py +++ b/express/parsers/apps/nwchem/settings.py @@ -12,10 +12,15 @@ r"Vector\s+\d+\s+Occ=\s*(?P[\dDEe.+-]+)\s+E=\s*(?P[\dDEe.+-]+)" ) +# HOMO is the highest-energy occupied orbital; LUMO the lowest-energy unoccupied one. +# Consumed by NwchemTXTParser._frontier_orbital_energy. +FRONTIER_ORBITAL_ENERGY = { + "homo_energy": {"occupied": True, "select": max}, + "lumo_energy": {"occupied": False, "select": min}, +} + REGEX = { "total_energy": {"regex": COMMON_REGEX.format("Total DFT energy"), "occurrences": -1, "output_type": "float"}, - # homo_energy / lumo_energy are parsed from the orbital analysis section instead; - # see NwchemTXTParser._converged_homo_energy / _converged_lumo_energy. "zero_point_energy": { "regex": COMMON_REGEX.format("Zero-Point correction to Energy"), "occurrences": -1, From 2eebd7d270e53e5e4d0d1866dacc5ee8e01a0e04 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 10:51:34 -0700 Subject: [PATCH 3/5] update: derive homo/lumo from eigenvalues at vectors Introduce eigenvalues_at_vectors as an intermediate quantity, the molecular analogue of eigenvalues_at_kpoints: declared in ElectronicDataMixin, parsed from the converged orbital analysis section, and converted to eV once in NwchemParser. HOMO and LUMO become plain max/min derivations over it. Drops the FRONTIER_ORBITAL_ENERGY settings dict, which held max/min callables and splatted them as kwargs into a private helper. Co-Authored-By: Claude Opus 5 (1M context) --- express/parsers/apps/nwchem/formats/txt.py | 65 +++++-------------- express/parsers/apps/nwchem/parser.py | 25 ++++--- express/parsers/apps/nwchem/settings.py | 14 ++-- express/parsers/mixins/electronic.py | 22 +++++++ tests/fixtures/nwchem/references.py | 18 +++-- .../parsers/apps/nwchem/test_parser.py | 12 ++++ tests/manifest.yaml | 8 +++ 7 files changed, 92 insertions(+), 72 deletions(-) diff --git a/express/parsers/apps/nwchem/formats/txt.py b/express/parsers/apps/nwchem/formats/txt.py index 217d7609..52dbc839 100644 --- a/express/parsers/apps/nwchem/formats/txt.py +++ b/express/parsers/apps/nwchem/formats/txt.py @@ -25,38 +25,31 @@ class NwchemTXTParser(BaseTXTParser): def __init__(self, work_dir): super(NwchemTXTParser, self).__init__(work_dir) - def _converged_orbital_block(self, text): + def eigenvalues_at_vectors(self, text): """ - Returns the text of the final molecular orbital analysis section, or an empty - string if it is not present. + Extracts eigenvalues at molecular orbitals (vectors). Geometry optimizations print one + orbital analysis section per step; the last one is read, for the final geometry. - Args: - text (str): text to extract data from. - - Returns: - str - """ - start_index = text.rfind(settings.FRONTIER_ORBITAL_BLOCK_START_FLAG) - return text[start_index:] if start_index != -1 else "" - - def _frontier_orbital_energy(self, text, occupied, select): - """ - Extracts a frontier orbital energy (Hartree) from the final orbital analysis section. + Units: + energy: Hartree Args: text (str): text to extract data from. - occupied (bool): select among occupied orbitals if True, unoccupied ones if False. - select (callable): reduces the matching energies to the frontier one (max or min). Returns: - float | None - """ - energies = [ - _fortran_float(orbital.group("energy")) - for orbital in settings.VECTOR_REGEX.finditer(self._converged_orbital_block(text)) - if (_fortran_float(orbital.group("occupation")) > 0) == occupied + list[dict] + """ + start_index = text.rfind(settings.ORBITAL_ANALYSIS_BLOCK_START_FLAG) + if start_index == -1: + return [] + return [ + { + "vector": int(orbital.group("vector")), + "occupation": _fortran_float(orbital.group("occupation")), + "energy": _fortran_float(orbital.group("energy")), + } + for orbital in settings.VECTOR_REGEX.finditer(text[start_index:]) ] - return select(energies) if energies else None def total_energy(self, text): """ @@ -87,30 +80,6 @@ def total_energy_contributions(self, text): energy_contributions.update({contribution: {"name": contribution, "value": value}}) return energy_contributions - def homo_energy(self, text): - """ - Extracts the HOMO energy. - - Args: - text (str): text to extract data from. - - Returns: - float | None - """ - return self._frontier_orbital_energy(text, **settings.FRONTIER_ORBITAL_ENERGY["homo_energy"]) - - def lumo_energy(self, text): - """ - Extracts the LUMO energy. - - Args: - text (str): text to extract data from. - - Returns: - float | None - """ - return self._frontier_orbital_energy(text, **settings.FRONTIER_ORBITAL_ENERGY["lumo_energy"]) - def zero_point_energy(self, text): """ Extracts zero point energy. diff --git a/express/parsers/apps/nwchem/parser.py b/express/parsers/apps/nwchem/parser.py index 667af6f9..b34492e6 100644 --- a/express/parsers/apps/nwchem/parser.py +++ b/express/parsers/apps/nwchem/parser.py @@ -49,25 +49,30 @@ def total_energy_contributions(self): value1[key2] = value2 * Constant.HARTREE return energy_contributions - def homo_energy(self): + def eigenvalues_at_vectors(self): """ - Returns HOMO energy. + Returns eigenvalues at molecular orbitals (vectors). Reference: + func: express.parsers.mixins.electronic.ElectronicDataMixin.eigenvalues_at_vectors NWChem orbital energies are defaulted to hartrees and are converted to eV in this method. """ - homo_energy = self.txt_parser.homo_energy(self._get_file_content(self.stdout_file)) - return None if homo_energy is None else Constant.HARTREE * homo_energy + orbitals = self.txt_parser.eigenvalues_at_vectors(self._get_file_content(self.stdout_file)) + return [dict(orbital, energy=Constant.HARTREE * orbital["energy"]) for orbital in orbitals] - def lumo_energy(self): + def homo_energy(self): + """ + Returns HOMO energy, the highest energy among the occupied molecular orbitals. """ - Returns LUMO energy. + energies = [orbital["energy"] for orbital in self.eigenvalues_at_vectors() if orbital["occupation"] > 0] + return max(energies) if energies else None - Reference: - NWChem orbital energies are defaulted to hartrees and are converted to eV in this method. + def lumo_energy(self): + """ + Returns LUMO energy, the lowest energy among the unoccupied molecular orbitals. """ - lumo_energy = self.txt_parser.lumo_energy(self._get_file_content(self.stdout_file)) - return None if lumo_energy is None else Constant.HARTREE * lumo_energy + energies = [orbital["energy"] for orbital in self.eigenvalues_at_vectors() if orbital["occupation"] == 0] + return min(energies) if energies else None def zero_point_energy(self): """ diff --git a/express/parsers/apps/nwchem/settings.py b/express/parsers/apps/nwchem/settings.py index b0b72b53..df006f8a 100644 --- a/express/parsers/apps/nwchem/settings.py +++ b/express/parsers/apps/nwchem/settings.py @@ -6,19 +6,13 @@ DOUBLE_REGEX = GENERAL_REGEX["double_number"] NWCHEM_OUTPUT_FILE_REGEX = "Northwest Computational Chemistry Package" -# Header of the final molecular orbital analysis section; VECTOR_REGEX matches its orbital lines. -FRONTIER_ORBITAL_BLOCK_START_FLAG = "DFT Final Molecular Orbital Analysis" +# TODO: spin-polarized (ODFT) runs emit "DFT Final Alpha/Beta Molecular Orbital Analysis" instead, +# which this flag does not match, leaving the eigenvalues empty. +ORBITAL_ANALYSIS_BLOCK_START_FLAG = "DFT Final Molecular Orbital Analysis" VECTOR_REGEX = re.compile( - r"Vector\s+\d+\s+Occ=\s*(?P[\dDEe.+-]+)\s+E=\s*(?P[\dDEe.+-]+)" + r"Vector\s+(?P\d+)\s+Occ=\s*(?P[\dDEe.+-]+)\s+E=\s*(?P[\dDEe.+-]+)" ) -# HOMO is the highest-energy occupied orbital; LUMO the lowest-energy unoccupied one. -# Consumed by NwchemTXTParser._frontier_orbital_energy. -FRONTIER_ORBITAL_ENERGY = { - "homo_energy": {"occupied": True, "select": max}, - "lumo_energy": {"occupied": False, "select": min}, -} - REGEX = { "total_energy": {"regex": COMMON_REGEX.format("Total DFT energy"), "occurrences": -1, "output_type": "float"}, "zero_point_energy": { diff --git a/express/parsers/mixins/electronic.py b/express/parsers/mixins/electronic.py index 1826c47a..65e5aba3 100644 --- a/express/parsers/mixins/electronic.py +++ b/express/parsers/mixins/electronic.py @@ -85,6 +85,28 @@ def eigenvalues_at_kpoints(self): """ pass + @abstractmethod + def eigenvalues_at_vectors(self): + """ + Returns eigenvalues at molecular orbitals (vectors), the molecular analogue of + `eigenvalues_at_kpoints` for systems without reciprocal space. + + Units: + energy: eV + + Returns: + list[dict] + + Example: + [ + {'vector': 1, 'occupation': 2.0, 'energy': -520.7098}, + {'vector': 2, 'occupation': 2.0, 'energy': -27.2698}, + {'vector': 3, 'occupation': 0.0, 'energy': 1.7931}, + ... + ] + """ + pass + @abstractmethod def dos(self): """ diff --git a/tests/fixtures/nwchem/references.py b/tests/fixtures/nwchem/references.py index 5f6537c8..abb10958 100644 --- a/tests/fixtures/nwchem/references.py +++ b/tests/fixtures/nwchem/references.py @@ -3,16 +3,26 @@ All nwchem output values are in hartrees. ExPrESS converts units to eV. All reference energies are in eV. """ + TOTAL_ENERGY = -2079.18666382721904 -# HOMO/LUMO from the final orbital analysis section. The *_INITIAL_GUESS values come from the -# initial-guess section and are used by the tests to check the parser does not return them. + +# Only the count and the edges of the spectrum are pinned; HOMO/LUMO below cover the frontier. +EIGENVALUES_AT_VECTORS_COUNT = 19 +EIGENVALUES_AT_VECTORS_FIRST = {"vector": 1, "occupation": 2.0, "energy": -520.7096480731956} +EIGENVALUES_AT_VECTORS_LAST = {"vector": 19, "occupation": 0.0, "energy": 96.85556141135692} + +# The *_INITIAL_GUESS values come from the initial-guess section preceding the SCF cycle; the tests +# assert the parser does not return them. HOMO_ENERGY = -7.938587261191046 LUMO_ENERGY = 1.793148251055798 HOMO_ENERGY_INITIAL_GUESS = -12.800485418916242 LUMO_ENERGY_INITIAL_GUESS = 3.1242763921882197 -# From test-002/nwchem-frequency.log, whose output contains several orbital analysis sections -# (one per geometry-optimization step); the parser must read the last one. +# test-002/nwchem-frequency.log holds one orbital analysis section per optimization step. +EIGENVALUES_AT_VECTORS_MULTISTEP_COUNT = 18 +EIGENVALUES_AT_VECTORS_MULTISTEP_FIRST = {"vector": 1, "occupation": 2.0, "energy": -520.5444749015667} +EIGENVALUES_AT_VECTORS_MULTISTEP_LAST = {"vector": 18, "occupation": 0.0, "energy": 70.15805296279406} + HOMO_ENERGY_MULTISTEP = -7.8966272890902385 LUMO_ENERGY_MULTISTEP = 1.7759215328081597 HOMO_ENERGY_MULTISTEP_INITIAL_GUESS = -10.187320671325603 diff --git a/tests/integration/parsers/apps/nwchem/test_parser.py b/tests/integration/parsers/apps/nwchem/test_parser.py index e05eaa2f..a0fbc66b 100644 --- a/tests/integration/parsers/apps/nwchem/test_parser.py +++ b/tests/integration/parsers/apps/nwchem/test_parser.py @@ -15,6 +15,18 @@ def tearDown(self): def test_nwchem_total_energy(self): self.assertAlmostEqual(self.parser.total_energy(), TOTAL_ENERGY, places=2) + def test_nwchem_eigenvalues_at_vectors(self): + eigenvalues = self.parser.eigenvalues_at_vectors() + self.assertEqual(len(eigenvalues), EIGENVALUES_AT_VECTORS_COUNT) + self.assertDeepAlmostEqual(eigenvalues[0], EIGENVALUES_AT_VECTORS_FIRST, places=2) + self.assertDeepAlmostEqual(eigenvalues[-1], EIGENVALUES_AT_VECTORS_LAST, places=2) + + def test_nwchem_eigenvalues_at_vectors_multistep(self): + eigenvalues = self.parser.eigenvalues_at_vectors() + self.assertEqual(len(eigenvalues), EIGENVALUES_AT_VECTORS_MULTISTEP_COUNT) + self.assertDeepAlmostEqual(eigenvalues[0], EIGENVALUES_AT_VECTORS_MULTISTEP_FIRST, places=2) + self.assertDeepAlmostEqual(eigenvalues[-1], EIGENVALUES_AT_VECTORS_MULTISTEP_LAST, places=2) + def test_nwchem_homo_energy(self): homo_energy = self.parser.homo_energy() self.assertAlmostEqual(homo_energy, HOMO_ENERGY, places=2) diff --git a/tests/manifest.yaml b/tests/manifest.yaml index e015095e..aa9c6001 100644 --- a/tests/manifest.yaml +++ b/tests/manifest.yaml @@ -2,6 +2,14 @@ test_nwchem_total_energy: workDir: fixtures/nwchem/test-001 stdoutFile: fixtures/nwchem/test-001/nwchem-total-energy.log +test_nwchem_eigenvalues_at_vectors: + workDir: fixtures/nwchem/test-001 + stdoutFile: fixtures/nwchem/test-001/nwchem-total-energy.log + +test_nwchem_eigenvalues_at_vectors_multistep: + workDir: fixtures/nwchem/test-002 + stdoutFile: fixtures/nwchem/test-002/nwchem-frequency.log + test_nwchem_homo_energy: workDir: fixtures/nwchem/test-001 stdoutFile: fixtures/nwchem/test-001/nwchem-total-energy.log From c94f3f8abd1c7e571deb92edbdb79fca2c4e125b Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 10:51:58 -0700 Subject: [PATCH 4/5] update: read orbital eigenvalues of spin-polarized runs Spin-polarized (ODFT) runs print "DFT Final Alpha/Beta Molecular Orbital Analysis" instead of the unlabeled closed-shell section, so the previous literal match found nothing and HOMO/LUMO came back empty. Match the section header with the spin label optional and keep the last block of each channel, which requires bounding a block at the next header rather than at EOF. HOMO/LUMO need no change: ODFT occupations are 1.0 per channel, so max/min over the combined list stays correct. Covered by unit tests, as both integration fixtures are closed shell. The Alpha/Beta header spellings still want confirming against a real open-shell run; if they are wrong the result is empty eigenvalues, as before. Co-Authored-By: Claude Opus 5 (1M context) --- express/parsers/apps/nwchem/formats/txt.py | 12 ++-- express/parsers/apps/nwchem/settings.py | 5 +- tests/unit/parsers/test_nwchem_txt_parser.py | 60 ++++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 tests/unit/parsers/test_nwchem_txt_parser.py diff --git a/express/parsers/apps/nwchem/formats/txt.py b/express/parsers/apps/nwchem/formats/txt.py index 52dbc839..6080cb6d 100644 --- a/express/parsers/apps/nwchem/formats/txt.py +++ b/express/parsers/apps/nwchem/formats/txt.py @@ -28,7 +28,8 @@ def __init__(self, work_dir): def eigenvalues_at_vectors(self, text): """ Extracts eigenvalues at molecular orbitals (vectors). Geometry optimizations print one - orbital analysis section per step; the last one is read, for the final geometry. + orbital analysis section per step; the last one is read, for the final geometry. A + spin-polarized run prints an alpha and a beta section per step, both of which are read. Units: energy: Hartree @@ -39,16 +40,17 @@ def eigenvalues_at_vectors(self, text): Returns: list[dict] """ - start_index = text.rfind(settings.ORBITAL_ANALYSIS_BLOCK_START_FLAG) - if start_index == -1: - return [] + blocks = list(settings.ORBITAL_ANALYSIS_BLOCK_REGEX.finditer(text)) + ends = [block.start() for block in blocks[1:]] + [len(text)] + last_block_per_spin = {block.group("spin"): text[block.end() : end] for block, end in zip(blocks, ends)} return [ { "vector": int(orbital.group("vector")), "occupation": _fortran_float(orbital.group("occupation")), "energy": _fortran_float(orbital.group("energy")), } - for orbital in settings.VECTOR_REGEX.finditer(text[start_index:]) + for block in last_block_per_spin.values() + for orbital in settings.VECTOR_REGEX.finditer(block) ] def total_energy(self, text): diff --git a/express/parsers/apps/nwchem/settings.py b/express/parsers/apps/nwchem/settings.py index df006f8a..b9f26d78 100644 --- a/express/parsers/apps/nwchem/settings.py +++ b/express/parsers/apps/nwchem/settings.py @@ -6,9 +6,8 @@ DOUBLE_REGEX = GENERAL_REGEX["double_number"] NWCHEM_OUTPUT_FILE_REGEX = "Northwest Computational Chemistry Package" -# TODO: spin-polarized (ODFT) runs emit "DFT Final Alpha/Beta Molecular Orbital Analysis" instead, -# which this flag does not match, leaving the eigenvalues empty. -ORBITAL_ANALYSIS_BLOCK_START_FLAG = "DFT Final Molecular Orbital Analysis" +# Closed-shell runs print a single unlabeled section, spin-polarized (ODFT) ones an Alpha and a Beta. +ORBITAL_ANALYSIS_BLOCK_REGEX = re.compile(r"DFT Final (?:(?PAlpha|Beta) )?Molecular Orbital Analysis") VECTOR_REGEX = re.compile( r"Vector\s+(?P\d+)\s+Occ=\s*(?P[\dDEe.+-]+)\s+E=\s*(?P[\dDEe.+-]+)" ) diff --git a/tests/unit/parsers/test_nwchem_txt_parser.py b/tests/unit/parsers/test_nwchem_txt_parser.py new file mode 100644 index 00000000..a3256d54 --- /dev/null +++ b/tests/unit/parsers/test_nwchem_txt_parser.py @@ -0,0 +1,60 @@ +import unittest + +from express.parsers.apps.nwchem.formats.txt import NwchemTXTParser + + +def orbital_analysis_block(spin, energies): + header = f"DFT Final {spin + ' ' if spin else ''}Molecular Orbital Analysis" + vectors = "\n".join( + f" Vector {index + 1:4d} Occ={occupation} E={energy}" for index, (occupation, energy) in enumerate(energies) + ) + return f"{header}\n{'-' * len(header)}\n\n{vectors}\n\n center of mass\n" + + +class NwchemTXTParserTest(unittest.TestCase): + """ + Covers the selection of orbital analysis sections, which the integration fixtures cannot + exercise: both of them are closed shell and single channel. + """ + + def setUp(self): + self.parser = NwchemTXTParser(work_dir=".") + + def test_reads_last_section_of_closed_shell_output(self): + text = orbital_analysis_block(None, [("2.000000D+00", "-1.0D+00")]) + orbital_analysis_block( + None, [("2.000000D+00", "-2.0D+00"), ("0.000000D+00", "3.0D-01")] + ) + self.assertEqual( + self.parser.eigenvalues_at_vectors(text), + [ + {"vector": 1, "occupation": 2.0, "energy": -2.0}, + {"vector": 2, "occupation": 0.0, "energy": 0.3}, + ], + ) + + def test_reads_both_channels_of_spin_polarized_output(self): + text = orbital_analysis_block("Alpha", [("1.000000D+00", "-9.0D+00")]) + orbital_analysis_block( + "Beta", [("1.000000D+00", "-8.0D+00"), ("0.000000D+00", "5.0D-01")] + ) + self.assertEqual( + self.parser.eigenvalues_at_vectors(text), + [ + {"vector": 1, "occupation": 1.0, "energy": -9.0}, + {"vector": 1, "occupation": 1.0, "energy": -8.0}, + {"vector": 2, "occupation": 0.0, "energy": 0.5}, + ], + ) + + def test_reads_last_step_of_each_channel(self): + text = "".join( + [ + orbital_analysis_block("Alpha", [("1.000000D+00", "-9.0D+00")]), + orbital_analysis_block("Beta", [("1.000000D+00", "-8.0D+00")]), + orbital_analysis_block("Alpha", [("1.000000D+00", "-7.0D+00")]), + orbital_analysis_block("Beta", [("1.000000D+00", "-6.0D+00")]), + ] + ) + self.assertEqual([orbital["energy"] for orbital in self.parser.eigenvalues_at_vectors(text)], [-7.0, -6.0]) + + def test_returns_nothing_without_an_orbital_analysis_section(self): + self.assertEqual(self.parser.eigenvalues_at_vectors(" Total DFT energy = -76.4\n"), []) From 634e2a85ced79031221076f37d6694f4442ee650 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 6 Aug 2026 17:30:02 -0700 Subject: [PATCH 5/5] update: move fortran float --- express/parsers/apps/nwchem/formats/txt.py | 15 +-------------- express/parsers/utils.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/express/parsers/apps/nwchem/formats/txt.py b/express/parsers/apps/nwchem/formats/txt.py index 6080cb6d..8262a0d3 100644 --- a/express/parsers/apps/nwchem/formats/txt.py +++ b/express/parsers/apps/nwchem/formats/txt.py @@ -1,20 +1,7 @@ from express.parsers.settings import Constant # noqa: F401 from express.parsers.apps.nwchem import settings from express.parsers.formats.txt import BaseTXTParser - - -def _fortran_float(value): - """ - Converts a Fortran-formatted float string (e.g. "-1.234D+01", double-precision - "D" exponent notation) to a Python float. - - Args: - value (str): Fortran-formatted number, e.g. "-1.234D+01". - - Returns: - float - """ - return float(value.replace("D", "E").replace("d", "e")) +from express.parsers.utils import _fortran_float class NwchemTXTParser(BaseTXTParser): diff --git a/express/parsers/utils.py b/express/parsers/utils.py index bf3312ea..63e1d895 100644 --- a/express/parsers/utils.py +++ b/express/parsers/utils.py @@ -80,3 +80,18 @@ def lattice_basis_to_poscar(lattice: dict, basis: dict, basis_units: str = "cart "\n".join([" ".join(["{0:14.9f}".format(v) for v in x["value"]]) for x in basis["coordinates"]]), ] ) + + + +def _fortran_float(value): + """ + Converts a Fortran-formatted float string (e.g. "-1.234D+01", double-precision + "D" exponent notation) to a Python float. + + Args: + value (str): Fortran-formatted number, e.g. "-1.234D+01". + + Returns: + float + """ + return float(value.replace("D", "E").replace("d", "e"))