[wien2k] Charge self-consistent DFT+DMFT driver - #22
Conversation
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
left a comment
There was a problem hiding this comment.
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.qdmftformat matchesdft_tools'calc_density_correction(dm_type='wien2k')byte-for-byte:muin Ry,betain Ry⁻¹, per-knn+nnrows ofRe Im, the blank throwaway record,correnerraw in eV. Round-tripped with a token reader; all values exact.- The
N_kconvention is right.modest::charge_density_correction(chargedensity.hpp) returns the full band-basis density matrix, zero-padded tomax_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_kis 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_tagsrecord pairing — verified against all three:ENEvariants (**INFO****/*WARNING**/**********) and an:ENE-without-:DISfirst cycle. Pairing by record rather than zipping two lists is the right call.- A single window line in
indmftpris correct for thisdmftproj(dmftproj.f,proj_mode = 0). - The converter always overwrites
dft_input, so_regenerate_projectorsreally does refresh the archive on every update. mpi.report(..., stderr=True)is supported by both TRIQS mpi backends.- Program ordering in
run_update_stagematches the docstring exactly (traced):lapw2 -qdmft,lcore, concat,mixer, concatscfm,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_updaterunstest/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,:nnslicing on the non-uniform window, spin average, no-mutation, excluded-k rejection),_read_oubwin,_scf_tags/_converged/_scf_history_on_diskon a syntheticcase.scf,_set_in2_mode,_cmplx, the_check_inputsmessages — and the entire SCF loop,run_update_stageordering and all three restart branches via stubx/dmftprojscripts. - The fixtures are already in-tree.
test/python/wien2k/SrVO3.oubwinhas genuinely non-uniform windows (5 / 4 / 4 / 3x7 bands), which is precisely the case that makes the:nnslicing worth testing. - The class docstring advertises the seam — "
wienrootanddmftproj_exeare 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 collectiveEvery 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_oubwinreturnsiso, which is really the SO flag (the converter asserts it equalsSO); every caller discards it, and it is never checked against the archive._RY_IN_EVis hardcoded as13.605698; the QE driver uses13.605693123; the archive carriesenergy_unitfromcase.ctqmcout. Only affectsread_dft_energy, but it is three values for one constant._prepare_fresh_scfmatches'.broyd' in name(substring) rather than af"{seedname}.broyd"prefix._run_scfraises "did not converge in{max_scf_iter}cycles", but with a seeded history the limit ismax_scf_iteradditional cycles, so the number in the message is not the number of cycles run._env(): an unsetSCRATCHmakes the tcshxscript 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_cycleraisesNameErroronGimp/Sigma_imp_iwifn_loops == 0. Guard it or documentn_dmft_loops >= 1. - Example reassigns
target_density, obe = ...update...whereas the VASP example keepstarget_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.
Summary
This PR provides a Wien2k Python driver that lets modest's
DftDriverrun 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
xscript rather than asingle executable, and its own DMFT support inverts the control flow:
run_lapwcalls out to a user python script from inside its cycle, whereas we wantDftDriverto be the caller. Thus, we write the SCF loop in python in place ofrun_lapw.run_update_stagewrites the DMFT density matrices tocase.qdmft, runslapw2 -qdmft, then lcore and mixer for the new density, lapw0 and lapw1 forthe 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.pyrun_initial_stage,run_update_stage,run_dft_only,scf_convergeddoc/examples/wien2k_csc_svo/Notes for review
lapw2 -qdmfthas no parallelversion. The impurity solver still uses all ranks.
case.scfis reused and a partial onecontinued, since modest calls
run_initial_stageunconditionally and keeps noDFT state across a resumed run.
force_scf=Trueredoes 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 plainrun_lapwSCF for comparison.