Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [unreleased]

### Fixed

- `decode()` no longer fails on ORCA Hessian calculations. Analytic Hessian jobs don't print a `CARTESIAN GRADIENT` block in ORCA stdout, so gradient parsing for `hessian` calctype now uses a dedicated, non-required `parse_gradient_hessian` parser instead of the `gradient`-calctype parser.
- ORCA optimization trajectory parsing now passes `charge` and `multiplicity` from `input_data.structure` into each `Structure` parsed from the `_trj.xyz` file instead of silently defaulting to `(0, 1)`.
- ORCA `parse_hessian()` recognizes that the final Hessian block entry may only contain a single column. See `tests/data/orca/single_column.hess` for an example.

## [0.11.1] - 2026-08-26

- Updated GitHub references from `coltonbh` to `atomsforhumanity`.

## [0.11.0] - 2026-07-15

### Fixed

- `decode()` now raises a parser error when output artifacts required by parser specs for the requested calculation type are missing, while preserving any partial data parsed from available artifacts.
Expand Down
4 changes: 2 additions & 2 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def update_version_in_pyproject(version: str) -> None:
"""
Update the version in pyproject.toml by replacing the line that sets the version
in the [project] section with the new version string.

This function uses a regex to find a line that starts with "version =", captures the surrounding quotes,
and substitutes the new version.

Expand All @@ -34,7 +34,7 @@ def update_version_in_pyproject(version: str) -> None:
# This regex matches a line starting with 'version = "', then any characters until the next '"'
new_content = re.sub(
r'^(version\s=\s")[^"]*(")',
r'\g<1>' + version + r'\g<2>',
r"\g<1>" + version + r"\g<2>",
content,
flags=re.MULTILINE,
)
Expand Down
57 changes: 43 additions & 14 deletions src/qccodec/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,16 @@ def decode(
as_dict: bool = False,
) -> StructuredData | dict[str, Any]:
"""Decode the output of a quantum chemistry program into a standardized output.

Args:
program: The QC program that generated the output file.
calctype: The type of calculation that was run.
stdout: The stdout file contents as a string.
directory: The directory containing the output files.
input_data: The input data used for the calculation.
This is used to provide additional context for the parsers.
as_dict: If True, return the results as a dictionary instead of a
StructuredData object. Used mostly for testing purposes to enable
as_dict: If True, return the results as a dictionary instead of a
StructuredData object. Used mostly for testing purposes to enable
returning parsed data that isn't a fully valid StructuredData object.

Returns:
Expand Down Expand Up @@ -93,38 +93,66 @@ def decode(
# Look up the parsers for the given program, filetype, and calctype
logger.debug("Processing file with filetype: %s", filetype)
parser_specs = registry.get_parsers(program, filetype, calctype)
logger.info("Found %d parser(s) for program '%s', filetype '%s', calctype '%s'", len(parser_specs), program, filetype, calctype) # noqa: E501

logger.info(
"Found %d parser(s) for program '%s', filetype '%s', calctype '%s'",
len(parser_specs),
program,
filetype,
calctype,
) # noqa: E501

for spec in parser_specs:
logger.debug("Running parser '%s' for target '%s'", spec.parser.__name__, spec.target) # noqa: E501
logger.debug(
"Running parser '%s' for target '%s'", spec.parser.__name__, spec.target
) # noqa: E501
# Parse the contents using the parser
try:
if spec.filetype == "directory":
parsed_value: Any = spec.parser(directory, stdout, input_data)
else:
parsed_value = spec.parser(contents)
logger.info("Parser '%s' succeeded; returned value: %s", spec.parser.__name__, parsed_value) # noqa: E501
logger.info(
"Parser '%s' succeeded; returned value: %s",
spec.parser.__name__,
parsed_value,
) # noqa: E501
# Raised if the parser can't find its data
except MatchNotFoundError as e:
if spec.required:
logger.error("Required parser '%s' failed; raising exception", spec.parser.__name__) # noqa: E501
logger.error(
"Required parser '%s' failed; raising exception",
spec.parser.__name__,
) # noqa: E501
raise
else:
logger.info("Parser '%s' did not find a match but is not required.", spec.parser.__name__) # noqa: E501
logger.info(
"Parser '%s' did not find a match but is not required.",
spec.parser.__name__,
) # noqa: E501
# Place the parsed value into the data collector
else:
# If the parser returns a dictionary, assign each key-value pair to the data collector
if isinstance(parsed_value, dict):
for key, value in parsed_value.items():
data_collector.add_data(key, value)
logger.debug("Assigned parsed value to target '%s' on data_collector", (spec.target, key))
logger.debug(
"Assigned parsed value to target '%s' on data_collector",
(spec.target, key),
)
# Otherwise, assign the parsed value to the specified target
else:
assert spec.target is not None, "Target must be specified for non-dictionary parsed values." # for mypy
assert spec.target is not None, (
"Target must be specified for non-dictionary parsed values."
) # for mypy
data_collector.add_data(spec.target, parsed_value)
logger.debug("Assigned parsed value to target '%s' on data_collector", spec.target) # noqa: E501

logger.info("Completed processing files; final data_collector state: %s", data_collector) # noqa: E501
logger.debug(
"Assigned parsed value to target '%s' on data_collector",
spec.target,
) # noqa: E501

logger.info(
"Completed processing files; final data_collector state: %s", data_collector
) # noqa: E501
required_specs = [
spec
for spec in registry.get_parsers(program, calctype=calctype)
Expand All @@ -145,6 +173,7 @@ def decode(
return dict(data_collector)
return RESULTS_TYPE_MAP[calctype](**data_collector)


def encode(inp_data: ProgramInput, program: str) -> NativeInput:
"""Encode a ProgramInput object to a NativeInput object.

Expand Down
4 changes: 2 additions & 2 deletions src/qccodec/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ def add_data(self, target: str | tuple[str, ...], value: Any) -> None:

@dataclass
class NativeInput:
"""Native input file data for a quantum chemistry program.
"""Native input file data for a quantum chemistry program.

Writing these files to disk should produce a valid input.

Attributes:
Expand Down
30 changes: 26 additions & 4 deletions src/qccodec/parsers/orca.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def parse_energy(contents: str) -> float:

@register(
filetype=OrcaFileType.STDOUT,
calctypes=[CalcType.gradient, CalcType.hessian],
calctypes=[CalcType.gradient],
target="gradient",
)
def parse_gradient(contents: str) -> list[list[float]]:
Expand Down Expand Up @@ -119,6 +119,20 @@ def parse_gradient(contents: str) -> list[list[float]]:
return gradient


@register(
filetype=OrcaFileType.STDOUT,
calctypes=[CalcType.hessian],
target="gradient",
required=False,
)
def parse_gradient_hessian(contents: str) -> list[list[float]]:
"""Parse the gradient from Orca stdout for a hessian calculation.

Analytic Hessian jobs don't print CARTESIAN GRADIENT block.
"""
return parse_gradient(contents)


@register(
filetype=OrcaFileType.HESS,
calctypes=[CalcType.hessian],
Expand All @@ -137,7 +151,8 @@ def parse_hessian(contents: str) -> list[list[float]]:
dim = int(entry.splitlines()[1])

# Split the hessian entry into blocks on lines of the form ' 0 1 2 3 ...'
split_result = re.split(r"^\s*(?:\d+\s+)+\d+\s*$", entry, flags=re.MULTILINE)
# (the final block may have only a single column index, e.g. ' 5')
split_result = re.split(r"^\s*\d+(?:\s+\d+)*\s*$", entry, flags=re.MULTILINE)
if not len(split_result) > 1:
raise ParserError(f"Failed to parse blocks in hessian entry: {entry}")

Expand Down Expand Up @@ -185,7 +200,13 @@ def parse_trajectory(
raise ParserError(f"Trajectory file does not exist: {file}")

# Parse the structures, energies, and gradients
structures = Structure.open_multi(file)
# NOTE: trj_xyz carries no (charge, multiplicity), so it will
# silently default to (0, 1) unless supplied from input_data
structures = Structure.open_multi(
file,
charge=input_data.structure.charge,
multiplicity=input_data.structure.multiplicity,
)

# Capture initialization stdout
regex = r"^(.*?\*\*\*\*END\s+OF\s+INPUT\*\*\*\*\s*\n\s*=*)"
Expand Down Expand Up @@ -258,6 +279,7 @@ def parse_basename(contents: str) -> str:
match = re_search(regex, contents)
return Path(match.group(1)).stem


@register(filetype=OrcaFileType.STDOUT, target="calcinfo_natoms", required=False)
def parse_natoms(contents: str) -> int:
"""Parse number of atoms value from Orca stdout.
Expand All @@ -270,4 +292,4 @@ def parse_natoms(contents: str) -> int:
"""
regex = r"Number of atoms\s*...\s*(\d+)"
match = re_search(regex, contents)
return int(match.group(1))
return int(match.group(1))
2 changes: 1 addition & 1 deletion src/qccodec/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def register(self, parser_spec: ParserSpec) -> None:
):
raise RegistryError(
f"Duplicate parser target '{parser_spec.target}' and calctype "
f"'{set(parser_spec.calctypes)& set(registered_spec.calctypes)}' "
f"'{set(parser_spec.calctypes) & set(registered_spec.calctypes)}' "
f"registered for program '{parser_spec.program}'."
)
self.registry[parser_spec.program].append(parser_spec)
Expand Down
45 changes: 44 additions & 1 deletion tests/data/crest/answers/frequencies.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
HF = [3533.1374]
water = [1665.9199, 3662.9967, 3663.6323]
caffeine = [-335.2821, 75.3406, 87.4971, 152.4883, 180.0606, 191.074, 219.3003, 246.2166, 278.5267, 280.7177, 359.0875, 382.8016, 449.069, 545.5556, 611.7482, 640.0248, 825.2501, 889.7146, 927.2648, 935.9104, 965.7738, 971.4924, 1066.8035, 1085.231, 1146.0758, 1210.0312, 1325.697, 1354.3222, 1377.8921, 1388.152, 1409.9827, 1415.4545, 1977.5598, 2058.9146, 2600.6465, 2992.0603, 2997.6282, 3014.3359, 3047.3809, 3083.5006, 3090.5364, 3205.2305]
caffeine = [
-335.2821,
75.3406,
87.4971,
152.4883,
180.0606,
191.074,
219.3003,
246.2166,
278.5267,
280.7177,
359.0875,
382.8016,
449.069,
545.5556,
611.7482,
640.0248,
825.2501,
889.7146,
927.2648,
935.9104,
965.7738,
971.4924,
1066.8035,
1085.231,
1146.0758,
1210.0312,
1325.697,
1354.3222,
1377.8921,
1388.152,
1409.9827,
1415.4545,
1977.5598,
2058.9146,
2600.6465,
2992.0603,
2997.6282,
3014.3359,
3047.3809,
3083.5006,
3090.5364,
3205.2305,
]
7 changes: 5 additions & 2 deletions tests/data/crest/answers/gradients.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
water = [[-0.005962071557911, -0.004419818102026, 0.003139227894649], [0.003048425211480, 0.001982394235964, -0.001779667371498], [0.002913646346432, 0.002437423866062, -0.001359560523152]]

water = [
[-0.005962071557911, -0.004419818102026, 0.003139227894649],
[0.003048425211480, 0.001982394235964, -0.001779667371498],
[0.002913646346432, 0.002437423866062, -0.001359560523152],
]
20 changes: 18 additions & 2 deletions tests/data/crest/answers/normal_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,21 @@
import numpy as np

HF = [[[-0.4157397476997121, 0.0, 0.0], [1.8330343421305486, 0.0, 0.0]]]
water = [[[-0.26456165762708955, 0.3590479639224786, -0.18897261259077824], [-0.0755890450363113, -0.9070685404357355, 0.9070685404357355], [1.152732936803747, -0.5480205765132568, -0.1511780900726226]], [[0.20786987384985606, -0.3023561801452452, 0.1700753513317004], [-1.152732936803747, 0.3401507026634008, 0.3401507026634008], [0.3590479639224786, 0.8314794953994242, -1.0015548467311246]], [[-0.3779452251815565, -0.09448630629538912, 0.321253441404323], [1.2283219818400586, -0.3779452251815565, -0.3590479639224786], [0.3023561801452452, 0.7747877116221906, -0.9070685404357355]]]
caffeine = np.load(Path(__file__).parent / "crest_g98big_normal_modes.npy").tolist()
water = [
[
[-0.26456165762708955, 0.3590479639224786, -0.18897261259077824],
[-0.0755890450363113, -0.9070685404357355, 0.9070685404357355],
[1.152732936803747, -0.5480205765132568, -0.1511780900726226],
],
[
[0.20786987384985606, -0.3023561801452452, 0.1700753513317004],
[-1.152732936803747, 0.3401507026634008, 0.3401507026634008],
[0.3590479639224786, 0.8314794953994242, -1.0015548467311246],
],
[
[-0.3779452251815565, -0.09448630629538912, 0.321253441404323],
[1.2283219818400586, -0.3779452251815565, -0.3590479639224786],
[0.3023561801452452, 0.7747877116221906, -0.9070685404357355],
],
]
caffeine = np.load(Path(__file__).parent / "crest_g98big_normal_modes.npy").tolist()
Loading
Loading