Skip to content

[wien2k] Charge self-consistent DFT+DMFT driver - #22

Open
harrisonlabollita wants to merge 3 commits into
TRIQS:unstablefrom
harrisonlabollita:wien2k_csc_driver
Open

[wien2k] Charge self-consistent DFT+DMFT driver#22
harrisonlabollita wants to merge 3 commits into
TRIQS:unstablefrom
harrisonlabollita:wien2k_csc_driver

Conversation

@harrisonlabollita

Copy link
Copy Markdown
Collaborator

Summary

This PR provides a Wien2k Python driver that lets modest's DftDriver run charge self-consistent DFT+DMFT calculations. This allows the TRIQS user to take control of the DFT+DMFT similar to the QE and VASP workflows entirely from their pythons script. We include a worked SrVO3 example to parallel the VASP example.

Wien2k is a chain of Fortran programs launched by the x script rather than a
single executable, and its own DMFT support inverts the control flow: run_lapw calls out to a user python script from inside its cycle, whereas we want DftDriver to be the caller. Thus, we write the SCF loop in python in place of run_lapw.

run_update_stage writes the DMFT density matrices to case.qdmft, runs
lapw2 -qdmft, then lcore and mixer for the new density, lapw0 and lapw1 for
the potential and eigenvectors that go with it, and finally rebuilds the
projectors so the HDF5 archive is current when it returns.

Contents

python/triqs_dftkit/wien2k/driver.py the driver: SCF loop, run_initial_stage, run_update_stage, run_dft_only, scf_converged
doc/examples/wien2k_csc_svo/ SrVO3 CSC example: struct, indmftpr, script, README

Notes for review

  • Scope is serial, non-magnetic (SP=0, SO=0). lapw2 -qdmft has no parallel
    version. The impurity solver still uses all ranks.
  • The SCF is restart-aware: a converged case.scf is reused and a partial one
    continued, since modest calls run_initial_stage unconditionally and keeps no
    DFT state across a resumed run. force_scf=True redoes it.

Testing

A SrVO3 csc run to convergence using the shipped example; A companion tutorial will be added on the ModEST side which will link the documentation between the two; run_dft_only() against a plain run_lapw SCF for comparison.

A driver that orchestrates the chain of Wien2k programs so modest's DftDriver
can drive charge self-consistency for Wien2k calculations from python.

WIEN2k is a chain of programs launched by the x script rather than a single
executable, and its own qdmft support inverts the control flow DftDriver needs.
The driver therefore owns the SCF loop in place of run_lapw: lapw0 -> lapw1 ->
lapw2 -> lcore -> mixer, tracking convergence from case.scfm.

run_update_stage writes the DMFT density matrices to case.qdmft, runs
lapw2 -qdmft (serial only), then lcore and mixer for the new density,
lapw0 and lapw1 for the potential and eigenvectors that go with it, and
finally rebuilds the projectors so the hdf5 archive is current on return.

Also handles an x-script side effect the driver has to work around: a stale
.oldin2 silently corrupting case.in2.

Adds run_dft_only() and scf_converged() for plain DFT runs and for stepping the
cycle one iteration at a time.

Scope: serial, non-magnetic (SP=0, SO=0).

@the-hampel the-hampel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review of the Wien2k CSC driver

Attribution: this review was produced by Claude (Opus 5) running in Claude Code, driven by @the-hampel. It is a static review plus local experiments — I have no Wien2k installation here, so anything that depends on Wien2k's own conventions is explicitly marked verify. Everything else was checked by running code.

Method. Replayed dmftproj.f's case.indmftpr reader against the shipped example; round-tripped _write_qdmft against the in-tree fixture test/python/wien2k/SrVO3.oubwin; and exercised the full SCF loop, run_update_stage and all three restart branches against stub x / dmftproj scripts (using the seam the class docstring advertises). Cross-checked against triqs_dftkit.vasp.driver, triqs_dftkit.qe.driver, triqs_modest.dft_driver.DftDriver, modest/c++/triqs_modest/chargedensity.hpp and dft_tools' SumkDFT.calc_density_correction(dm_type='wien2k').

Overall this is solid work and the best-documented driver in the tree — the "why" comments (_scf_tags record pairing, _save_old, the three qdmft conventions) are genuinely worth having. Two blockers, both concrete and both cheap to fix.

Verified correct — no need to re-check these

  • case.qdmft format matches dft_tools' calc_density_correction(dm_type='wien2k') byte-for-byte: mu in Ry, beta in Ry⁻¹, per-k nn + nn rows of Re Im, the blank throwaway record, correner raw in eV. Round-tripped with a token reader; all values exact.
  • The N_k convention is right. modest::charge_density_correction (chargedensity.hpp) returns the full band-basis density matrix, zero-padded to max_k N_nu(0,k), with index 0 = that k-point's own lowest in-window band. So writing the full matrix (not the deviation, unlike VASP/QE), unweighted, sliced :nn, is correct. The fixture has non-uniform windows so this is actually exercised.
  • N_k is not mutated. VASP and QE both clobber the caller's array in place; nice that this one doesn't, and the comment saying why is worth keeping.
  • _scf_tags record pairing — verified against all three :ENE variants (**INFO**** / *WARNING** / **********) and an :ENE-without-:DIS first cycle. Pairing by record rather than zipping two lists is the right call.
  • A single window line in indmftpr is correct for this dmftproj (dmftproj.f, proj_mode = 0).
  • The converter always overwrites dft_input, so _regenerate_projectors really does refresh the archive on every update.
  • mpi.report(..., stderr=True) is supported by both TRIQS mpi backends.
  • Program ordering in run_update_stage matches the docstring exactly (traced): lapw2 -qdmft, lcore, concat, mixer, concat scfm, lapw0, lapw1, lapw2 -almd, dmftproj, convert.

1. Blockers

1.1 SrVO3.indmftpr puts the correlated t2g shell on Sr, not V

Replaying dmftproj.f's reader against the shipped file:

sort 1 = Sr (ATOM 1, MULT=1):  lsort=[0,0,2,0]  lnreps=[0,0,2,0]  correps=01  SO=0   <-- CORRELATED
sort 2 = V  (ATOM 2, MULT=1):  lsort=[1,0,0,0]  lnreps=[0,0,0,0]                     <-- only s
sort 3 = O  (ATOM 3, MULT=3):  lsort=[0,1,0,0]  lnreps=[0,0,0,0]

The struct order is Sr, V, O(x3), and nmult = 1 1 3 confirms the mapping. The README says "we project onto the V-t2g shell" and the script builds a three-orbital Kanamori with U = 4.5, J = 0.65 for vanadium. Because Sr-d t2g is also three orbitals, the run completes and produces silently wrong physics — no error anywhere.

Fix: swap the sort-1 and sort-2 blocks (Sr becomes 0 0 0 0 / 0 0 0 0; V gets 0 0 2 0 / 0 0 2 0 / 01 / 0). fortran/dmftproj/SRC_templates/case.indmftpr in this repo already has the correct shape to copy from.

1.2 .restart is written and never removed

Traced through two restart cycles with the stub Wien2k:

restarted update stage   -> .restart present AFTER the mixer that consumed it: True
another update stage     -> .restart present: True

_mixing_flush_pending is one-shot, but the file is not. After a single flush, every subsequent mixer call in the run — and every future run in that directory — sees .restart. If mixer honours it, which the whole design assumes, the CSC loop can never build mixing state. _prepare_fresh_scf doesn't clear a stale one either, so a crash poisons the directory permanently.

Fix: os.remove('.restart') immediately after the mixer call in _run_mixer, and clear it in _prepare_fresh_scf. Better still, drop .restart in favour of removing case.broyd* — unambiguous, what run_lapw does, and already what _prepare_fresh_scf does.

Verify: who actually consumes .restart in SRC_mixer? If nothing does, _flush_mixing_history is a no-op that emits a reassuring warning while the inherited Broyden history is still used — worse than not having it, because the warning tells the user the problem was handled.


2. Should fix

2.1 Complex cases are silently unsupported

_cmplx gates in1c / in2c naming, _check_inputs and _set_in2_mode, but -c is never passed to x lapw1, x lapw2, lapw2 -almd or lapw2 -qdmft, and x does not auto-detect. Either thread the flag through _run_x or reject self._cmplx up front in _check_inputs — the current state implies support that isn't there.

2.2 Restart hole in run_initial_stage

On a resumed CSC run it reuses case.scf and goes straight to lapw2 -almd with no lapw0 / lapw1 (confirmed in the trace). If the interrupted run died between mixer and lapw1 completing — a real window, that's mixer + lapw0 + lapw1 of wall time — case.vector belongs to the pre-update potential while case.clmsum is post-update, so the projectors and H(k) are silently inconsistent with the density on disk.

The code already knows it is resuming (_has_dmft_cycles() is computed right there for the mixing flush); re-running lapw0 + lapw1 in that branch is cheap next to the solver.

2.3 _write_qdmft doesn't cross-check nn against the archive

The only guard is nn > Nk_avg.shape[1], i.e. greater than max_k n_orbitals. If case.oubwin and dft_input/n_orbitals ever disagree at some k with nn smaller than the archive value, the top-left nn x nn sub-block is written with no error and no warning. dft_tools asserts equality in the same place (assert nb == self.n_orbitals[ik, ntoi[bname]], sumk_dft.py); worth mirroring.

2.4 Eint_m_dc multiplicity is not handled

dft_tools' doc/guide/dftdmft_selfcons.rst is explicit: correnerg must be multiplied by the multiplicity of equivalent correlated atoms, and summed over inequivalent ones, before it goes into case.qdmft. _write_qdmft writes it raw. SrVO3 has a single V so the shipped example is fine, but any multi-atom cell gets a wrong total energy with nothing to indicate it. At minimum document this next to the three other convention notes in the _write_qdmft docstring; better, scale by MULT from corr_shells.

2.5 Test coverage

This is the weakest part of the PR: 831 lines of new driver logic, zero tests, CMakeLists.txt untouched.

  • The precedent exists and is registered in CTest — Py_vasp_charge_update runs test/python/vasp/charge_update/test_gamma_writer.py, which tests the analogous VASP charge-update writer with no VASP anywhere.
  • Roughly 80% of this driver is testable with no Wien2k at all. I covered it in ~150 lines of throwaway script while writing this review: _write_qdmft (format, :nn slicing on the non-uniform window, spin average, no-mutation, excluded-k rejection), _read_oubwin, _scf_tags / _converged / _scf_history_on_disk on a synthetic case.scf, _set_in2_mode, _cmplx, the _check_inputs messages — and the entire SCF loop, run_update_stage ordering and all three restart branches via stub x / dmftproj scripts.
  • The fixtures are already in-tree. test/python/wien2k/SrVO3.oubwin has genuinely non-uniform windows (5 / 4 / 4 / 3x7 bands), which is precisely the case that makes the :nn slicing worth testing.
  • The class docstring advertises the seam — "wienroot and dmftproj_exe are the only coupling to WIEN2k, so a fake WIEN2k can be substituted for testing by pointing them elsewhere" — and then nothing uses it.
  • Most telling: both blockers above live in the untested surface, and 1.2 is exactly what a twenty-line stub test catches. That is how I found it.

Genuinely needs a real Wien2k: .restart semantics, .oldin2 names, the testconv criterion, _SCF_PARTS ordering, and whether lapw2 -qdmft consumes the file correctly. Everything else is fair game for CI.

2.6 SP = 1 support was removed

The previous driver.py wrote .qdmftup / .qdmftdn for SP != 0; the new one raises for n_sigma != 2 and has no SP handling. Consistent with the stated scope, but it is a removal as well as an addition — worth being explicit about in the PR description so nobody is surprised.


3. Consistency

3.1 run_dft_only(n_iter=N) breaks the master-only + bcast pattern

The only place in the file that does:

if not self._scf_history_on_disk():      # every rank reads case.scf
    self._prepare_fresh_scf()
    mpi.barrier(poll_msec=100)           # conditional collective

Every other decision in the driver is ... if mpi.is_master_node() else None followed by mpi.bcast. If the ranks ever disagree — a lagging shared filesystem is enough — some enter the barrier and some don't, and the job hangs. One-line fix.

3.2 Master-only raises followed by a collective

_check_inputs, _run_x, _run_checked, _read_scfm, _write_qdmft and _run_dmftproj all raise on rank 0 only; the other ranks then block in the next mpi.bcast / mpi.barrier. The VASP and QE drivers have the same shape, so this is not a new problem — but this driver has many more such sites, and a small helper that broadcasts the failure flag would pay for itself.

3.3 _converged treats a missing :DIS as charge-converged

dis_ok = dis is None or dis < self.ccut is fine for the first cycle, but scf_converged() and _ensure_converged_scf use the same predicate to decide whether to reuse an existing SCF. A three-record history whose newest record lost its :DIS — which the driver's own _read_scfm warning calls "meaningless" — reports converged, and the SCF is skipped. On the reuse path, require dis is not None.

3.4 Energy label collision across drivers

VASP and QE print DFT + DMFT Total Energy before the update, as dft_energy + band_energy_correction + Eint_m_dc. This driver prints the same label after the update, read from case.scf. Both are right for their code, but they are different quantities at different points in the cycle and share a label — worth distinguishing in the text.

Nits (click to expand)
  • _read_oubwin returns iso, which is really the SO flag (the converter asserts it equals SO); every caller discards it, and it is never checked against the archive.
  • _RY_IN_EV is hardcoded as 13.605698; the QE driver uses 13.605693123; the archive carries energy_unit from case.ctqmcout. Only affects read_dft_energy, but it is three values for one constant.
  • _prepare_fresh_scf matches '.broyd' in name (substring) rather than a f"{seedname}.broyd" prefix.
  • _run_scf raises "did not converge in {max_scf_iter} cycles", but with a seeded history the limit is max_scf_iter additional cycles, so the number in the message is not the number of cycles run.
  • _env(): an unset SCRATCH makes the tcsh x script abort with a message the driver cannot explain — cheap to check in _check_inputs. The whitelist also drops MKL/OMP tuning and locale.
  • README says mpirun -n 16; worth stating that those 16 ranks idle through the whole lapw chain, so users size the job knowing that.
  • Example: dmft_cycle raises NameError on Gimp / Sigma_imp_iw if n_loops == 0. Guard it or document n_dmft_loops >= 1.
  • Example reassigns target_density, obe = ...update... whereas the VASP example keeps target_density. The Wien2k version is arguably more correct, since the dmftproj window can shift between updates — just flagging the divergence so it is a decision rather than an accident.

Not verifiable without a Wien2k installation

.restart semantics, the .oldin2 / .oldin2a names, the exact testconv energy criterion, the _SCF_PARTS ordering, the _LAPW0_SAVE / _MIXER_SAVE sets, and the fort.225 / fort.77 cleanup. These all read plausibly against run_lapw, but someone with Wien2k on disk should confirm them.

the-hampel pushed a commit to krystophny/dftkit that referenced this pull request Sep 10, 2026
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.

2 participants