Skip to content

Parse Q-Chem custom SMD solvents and fragmented $molecule sections - #1537

Open
rohithsrinivaas wants to merge 1 commit into
materialsproject:mainfrom
rohithsrinivaas:qchem-custom-smd-and-fragments
Open

rohithsrinivaas wants to merge 1 commit into
materialsproject:mainfrom
rohithsrinivaas:qchem-custom-smd-and-fragments

Conversation

@rohithsrinivaas

@rohithsrinivaas rohithsrinivaas commented Oct 20, 2026 •

Copy link
Copy Markdown

Two fixes for Q-Chem task documents (emmet.core.qchem):

1. Custom SMD solvents (solvent other). Calculation.from_qchem_files calls lot_solvent_string(input_doc, validate_lot=...) without custom_smd, so TaskDoc.from_directory raises "SMD calculation with solvent=other requires
custom_smd!"
for every calculation in a custom SMD solvent, even though the seven solvent descriptors are in the input's $smx section (which is where atomate2's QCInputGenerator writes them). In practice every atomate2 Q-Chem job with
smd_solvent="other" fizzles after Q-Chem has finished successfully.

solvent() now builds custom_smd from $smx (epsilon, soln, sola, solb, solg, solc, solh) when it is not given, through a new custom_smd_from_smx helper used by both solvent() implementations (calc_types/utils.py and
calculation.py). An explicit custom_smd still takes precedence, and solvent other without descriptors still raises.

2. Fragmented $molecule sections. A $molecule section split into fragments (e.g. fragment-based Hirshfeld constrained DFT, cdft_pop = fbh, or EDA) is read by pymatgen as a list of Molecules, which CalculationInput.from_qcinput
rejected ("QCInput contains multiple molecules"). The fragments are now combined into one Molecule with the total charge and the high-spin multiplicity that pymatgen's QCInput.molecule_template writes for them. molecule = "read" is
still rejected.

Test files (Q-Chem 6.2): a wB97M-V/def2-SVPD single point in a custom SMD solvent (test_files/qchem/qchem_custom_smd_sp_test) and a fragment-based Hirshfeld CDFT single point of Li⁺ / ethylene carbonate radical anion with fragments and a
custom SMD solvent (test_files/qchem/qchem_cdft_fragments_sp_test).

Contributor Checklist

  • I have broken down my PR scope into the following TODO tasks
    • Build custom_smd from the $smx section in both solvent() implementations (new custom_smd_from_smx helper)
    • Combine fragmented $molecule sections into one Molecule in CalculationInput.from_qcinput
    • Add Q-Chem test files for a custom SMD single point and a fragment-based CDFT single point
  • I have run the tests locally and they passed. (tests/qchem, tests/test_qc_task.py, tests/molecules: 35 passed, 2 skipped — the skips are the existing "Pymatgen OBAlign needs fix" ones; ruff 0.12.0, black 25.1.0 and mypy clean)
  • I have added tests, or extended existing tests, to cover any new features or bugs fixed in this PR (extended test_solvent; new test_custom_smd_from_smx, test_custom_smd_solvation_lot, test_fragments_input and
    test_input_with_molecule_read. The new tests fail on main without this change.)

Two fixes for Q-Chem task documents:

- Custom SMD solvents ("solvent other"): Calculation.from_qchem_files calls
  lot_solvent_string without custom_smd, so TaskDoc.from_directory raised
  "SMD calculation with solvent=other requires custom_smd!" for every calculation
  in a custom SMD solvent, even though the solvent descriptors are in the input's
  $smx section (atomate2 writes them there). solvent() now builds custom_smd from
  $smx (epsilon, soln, sola, solb, solg, solc, solh) when it is not given, through
  a new custom_smd_from_smx helper used by both solvent() implementations. An
  explicit custom_smd still takes precedence.

- Fragments: a $molecule section split into fragments (e.g. fragment-based
  Hirshfeld constrained DFT, EDA) is read by pymatgen as a list of Molecules, which
  CalculationInput.from_qcinput rejected ("QCInput contains multiple molecules").
  The fragments are now combined into one Molecule with the total charge and the
  high-spin multiplicity that pymatgen's QCInput writes for them; "read" is still
  rejected.

Test files: a wB97M-V/def2-SVPD single point in a custom SMD solvent and a
fragment-based Hirshfeld CDFT single point (Li+ / ethylene carbonate radical
anion, Q-Chem 6.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.43%. Comparing base (7d0d462) to head (8bd7283).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1537      +/-   ##
==========================================
+ Coverage   85.37%   85.43%   +0.06%     
==========================================
  Files         216      216              
  Lines       17793    17809      +16     
==========================================
+ Hits        15190    15215      +25     
+ Misses       2603     2594       -9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tsmathis tsmathis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple questions about the added test files. What are those? Just plain input/output files from these types of workflows?

I see you have a linked atomate2 PR, will this need an emmet release?

#: $smx keys of a custom ("solvent other") SMD solvent, in the order of custom_smd:
#: dielectric constant, refractive index, Abraham acidity and basicity, surface
#: tension, aromaticity and electronegative halogenicity
CUSTOM_SMD_KEYS = ("epsilon", "soln", "sola", "solb", "solg", "solc", "solh")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these originate from some external docs?
Or are these just standard qchem terms/fields? Like how VASP INCARs have known fields, etc.

else None
),
initial_molecule=molecule,
charge=(int(molecule.as_dict()["charge"]) if molecule.as_dict() else None),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason not to do just int(molecule.charge)?

I know the as_dict() ternary existed before this PR, but do you know if it is actually necessary to guard on molecule.as_dict()?
There is already the early error above on if not isinstance(molecule, Molecule): ..., so the molecule should at least be valid here (?)

Comment on lines +146 to 147
if not isinstance(molecule, Molecule):
raise ValueError("QCInput contains multiple molecules.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error message doesn't really communicate to the caller what went wrong now that there are multiple validation checks.

There are at least two error paths: 1. molecule is a list and not every member of the list is a Molecule, 2. molecule is a str-literal, (and I guess 3. is that molecule from qcinput.molecule is not a Molecule?)

It would be good to disambiguate the reason from_qcinput failed.

Comment on lines +136 to +145
if isinstance(molecule, list) and all(
isinstance(m, Molecule) for m in molecule
):
molecule = Molecule(
species=[site.species for fragment in molecule for site in fragment],
coords=[site.coords for fragment in molecule for site in fragment],
charge=sum(fragment.charge for fragment in molecule),
spin_multiplicity=1
+ sum(fragment.spin_multiplicity - 1 for fragment in molecule),
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This iterates over the sites twice and the fragments four times (and +1 for the initial iteration in the all(...)). A single-pass version should be possible, something like?

species, coords, charge, spin = [], [], 0, 1
for fragment in molecule:
    for site in fragment:
        species.append(site.species)
        coords.append(site.coords)
    charge += fragment.charge
    spin += fragment.spin_multiplicity - 1

molecule = Molecule(
    species=species,
    coords=coords,
    charge=charge,
    spin_multiplicity=spin,
)

could also technically skip the all(...), check if each fragment is a Molecule in the loop, and break early if any fragment is not a Molecule

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants