Skip to content

Refactor VASP pseudopotential parsing to use declarative mapping annotations#144

Open
ndaelman-hu wants to merge 20 commits into
developfrom
refactor/idiomatic-vasp-pseudopotential-parsing
Open

Refactor VASP pseudopotential parsing to use declarative mapping annotations#144
ndaelman-hu wants to merge 20 commits into
developfrom
refactor/idiomatic-vasp-pseudopotential-parsing

Conversation

@ndaelman-hu

@ndaelman-hu ndaelman-hu commented Feb 4, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Refactor VASP pseudopotential extraction to align with the declarative nature of the mapping parser framework, replacing imperative post-processing with annotation-driven data transformation.

Supersedes: #144 (old pseudopotential parsing PR)

Scope

Included

  • Declarative pseudopotential extraction from vasprun.xml using mapping annotations
  • Multi-pass parsing: OUTCAR extends vasprun.xml pseudopotentials with detailed metadata
  • Fixed critical JMESPath path resolution bugs in VASP schema
  • Explicit update_mode='merge' documentation for multi-file parsing strategy

Out of scope

  • Comprehensive test suite (deferred - requires deeper investigation into collection annotation patterns)

Context & Links

This approach aligns with the mapping parser's declarative philosophy:

  • Uses mapping annotations to declare "what to extract"
  • Leverages framework's built-in multi-pass parsing (XML_KEY, OUTCAR_KEY)
  • Collection annotations (m_def) declaratively add items to repeating subsections
  • Transformer functions are pure data transformation only
  • Framework handles merging, path resolution, type conversion automatically

Key technical fixes:

  1. JMESPath paths: XML section mappings require absolute paths from document root (modeling.parameters not .parameters)
  2. Transformer paths: Collection annotation transformer args use paths relative to m_def context (atominfo.array not modeling.atominfo.array)

These bugs prevented proper data extraction and required significant debugging through the mapping parser internals.

Reviewer Notes

Critical path resolution rules (took extensive debugging to discover):

  1. Section mapping paths (for DFT, ModelSystem, etc.): Use absolute paths from XML root

    • Correct: modeling.parameters.separator[...]
    • Incorrect: .parameters.separator[...]
  2. Transformer function_args paths: Use paths relative to m_def context

    • Correct when m_def='modeling': ['atominfo.array']
    • Incorrect: ['modeling.atominfo.array']

This was counter-intuitive and not well-documented in mapping_parser.py. I've submitted documentation improvements to nomad-FAIR in branch docs/expand-mapping-parser.

Trade-offs:

  • Cleaner, more maintainable code aligned with framework design
  • Automatic handling of merging, type conversion, path resolution
  • Clear separation: parsers extract data, transformers reshape it, framework populates schema
  • Requires understanding mapping parser internals for debugging
  • Path resolution rules are subtle and easy to get wrong

Status

  • Ready for review

Breaking Changes

  • None

Dependencies / Blockers

  • None
  • Recommendation: Merge nomad-FAIR PR docs/expand-mapping-parser to improve mapping parser documentation

Testing / Validation

  • Existing tests pass (test_vasprun, test_outcar)
  • Manual validation: 2 pseudopotentials correctly extracted from vasprun.xml with full metadata from OUTCAR
  • Comprehensive test suite deferred (needs investigation into collection annotation test patterns)

cc @ladinesa - This supersedes the previous pseudopotential parsing work and takes a more framework-aligned approach using declarative mapping annotations.

Refactor pseudopotential parsing to follow mapping parser framework idiomatically:

**Schema (vasp.py)**
- Add `Pseudopotential` class extending `numerical_settings.Pseudopotential`
- Add `sha256` field for POTCAR file identification
- Add collection annotations creating instances from transformer output
- Add field annotations mapping dict keys to schema fields
- Support both OUTCAR (complete metadata) and XML (limited metadata)

**OUTCAR Transformer (outcar_parser.py)**
- Add quantity definition to parse POTCAR headers from OUTCAR
- Implement `get_pseudopotentials()` transformer performing ALL derivations:
  - Extract basic metadata (name, valence, r_core, l_max, sha256)
  - Derive `type` from LPAW/LULTRA flags and name patterns
  - Derive `is_norm_conserving` and `is_gw_optimized` from type
  - Derive XC functional from LEXCH parameter using VASP mapping
  - Derive cutoffs from ENMAX/ENMIN
- Return plain dicts (not Section objects) following idiomatic pattern
- No post-processing needed - all logic in transformer

**XML Transformer (xml_parser.py)**
- Implement `get_pseudopotentials_xml()` transformer
- Extract limited metadata from vasprun.xml atomtypes array
- Return name and valence electrons only (XML lacks detailed PP data)
- Designed for multi-pass supplementing if OUTCAR unavailable

**Key Improvements**
- Declarative: Annotations control behavior, not imperative code
- Single responsibility: Each transformer does ONE thing completely
- No post-processing: All derivations happen during mapping
- Reusable: Transformers can be used across different parsers
- Maintainable: Logic centralized in transformers, not scattered

Follows patterns from mapping_parser.py documentation added in docs/add-mapping-parser-pydocs.
Implements complete pseudopotential metadata extraction from VASP OUTCAR and vasprun.xml files using the mapping parser framework idiomatically. The parser now recognizes OUTCAR files at the parser level by overriding `is_mainfile()` to validate VASP content signatures, allowing direct parsing without modifying entry point regex.

Schema annotations in `vasp.py` support field-level enrichment where XML creates basic instances with name and n_valence_electrons, while OUTCAR enriches with complete metadata including type, xc_functional, cutoffs, and all other fields. The framework automatically merges by position during multi-pass parsing.

The OUTCAR transformer extracts POTCAR headers via regex and derives all metadata idiomatically: PP type from LPAW/LULTRA flags, XC functional keys from LEXCH parameter mapping to LibXC aliases, and cutoffs from ENMAX/ENMIN values. The XML transformer provides limited metadata extraction from atomtypes arrays in vasprun.xml.

Nested subsections are handled by returning dicts that the framework converts: XC functional dicts with `functional_key` get expanded to LibXC components during normalization, and cutoff lists create `PPCutoff` instances with proper unit conversion from eV to joules.

All fields now populate correctly including sha256 hashes for POTCAR library matching. OUTCAR parsing works completely while vasprun.xml parsing is preserved without regression.
- Corrected XML array index in `get_pseudopotentials_xml()` from `c_elements[2]` (atomic mass) to `c_elements[4]` (pseudopotential name), now correctly extracting names like "PAW_PBE Mn_pv 02Aug2007" instead of mass values
- Relocated pseudopotential collection annotations from standalone `Pseudopotential` class to `ModelMethod` class to ensure pseudopotentials are properly added to `model_method.numerical_settings`
- Set `mainfile_alternative=False` for VASP XML parser entry point

Pseudopotentials now correctly appear in archive with name and `n_valence_electrons` from vasprun.xml files.
Modified `XMLArchiveWriter` to automatically detect and parse OUTCAR files in the same directory as vasprun.xml, using a third parsing pass with `OUTCAR_KEY` to extend pseudopotential metadata. The mapping framework merges data by list position, allowing OUTCAR to supplement the basic XML metadata (name, n_valence_electrons) with detailed fields (type, reference_configuration, cutoffs, xc_functional, r_core, l_max, lm_max, is_norm_conserving, is_gw_optimized).

This multi-pass parsing approach enables vasprun.xml to provide the basic structure while OUTCAR adds comprehensive pseudopotential details when available, gracefully degrading to XML-only data when OUTCAR is absent.
Added explicit `update_mode='merge'` parameter to the `outcar_parser.convert()` call in `XMLArchiveWriter` to document the merging strategy. While 'merge' is the default, being explicit improves code clarity and aligns with patterns used in other parsers (octopus, exciting, abinit).

The 'merge' mode aligns pseudopotentials by index position (source[0]→target[0]), recursively merging dict keys. This allows OUTCAR to extend XML pseudopotentials with detailed metadata (type, cutoffs, xc_functional) while preserving the XML structure.
Changed relative paths like  to absolute paths like
in XML parsing queries. The mapping parser's XMLParser requires absolute paths from
the document root element.

This fixes DFT model_method creation from minimal vasprun.xml files.
Changed path from 'modeling.atominfo.array' to 'atominfo.array' in the
get_pseudopotentials_xml transformer arguments. Transformer paths in collection
annotations should be relative to the m_def root context (set to 'modeling'
for XML parsing) without including the root prefix.

This follows the pattern used in other XML transformers like get_eigenvalues
which uses ['eigenvalues'] not ['modeling.eigenvalues'].
@ndaelman-hu ndaelman-hu mentioned this pull request Feb 4, 2026
@ndaelman-hu ndaelman-hu requested a review from ladinesa February 4, 2026 10:25
@ndaelman-hu ndaelman-hu self-assigned this Feb 4, 2026
Fix line length violations (E501) and magic value warnings (PLR2004) by:
- Breaking long docstrings and comments across multiple lines
- Extracting array indices to named variables with explanatory comments
@coveralls

coveralls commented Feb 4, 2026

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 23485291974

Details

  • 67 of 75 (89.33%) changed or added relevant lines in 4 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.1%) to 71.058%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/nomad_simulation_parsers/parsers/vasp/xml_parser.py 18 19 94.74%
src/nomad_simulation_parsers/parsers/vasp/parser.py 3 5 60.0%
src/nomad_simulation_parsers/parsers/vasp/outcar_parser.py 14 19 73.68%
Totals Coverage Status
Change from base Build 23445776916: 0.1%
Covered Lines: 6440
Relevant Lines: 9063

💛 - Coveralls

Comment thread src/nomad_simulation_parsers/parsers/vasp/outcar_parser.py Outdated


class VASPParser(MatchingParser):
def is_mainfile(

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.

why is this necessary? is the mainfile_alternative insufficient?

Comment thread src/nomad_simulation_parsers/parsers/vasp/xml_parser.py Outdated
mainfile_mime_re='(application/.*)|(text/.*)',
mainfile_name_re='.*[^/]*xml[^/]*',
mainfile_alternative=True,
mainfile_alternative=False,

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.

no do not do this

Comment thread src/nomad_simulation_parsers/schema_packages/vasp.py
Comment thread tests/parsers/test_vasp_parser.py Outdated
LOGGER = get_logger(__name__)


def approx(value, abs=0, rel=1e-6):

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.

we move this to parser utils

Comment thread src/nomad_simulation_parsers/parsers/vasp/xml_parser.py
Changes:
- Set `mainfile_alternative=True` in parser entry point to enable auxiliary file processing
- Enhanced `is_mainfile()` documentation explaining why override is necessary for standalone OUTCAR files
- Simplified `_find_outcar()` to use basename check with `iterdir()` instead of explicit candidate list
- Made filename matching case-insensitive for both discovery methods

This creates a comprehensive OUTCAR discovery strategy that handles both standalone OUTCAR parsing and automatic discovery when accompanying vasprun.xml files.

Testing confirms both scenarios work correctly:
- vasprun.xml + OUTCAR: Extracts 2 pseudopotentials with complete metadata (name, type, valence, XC, cutoffs)
- Standalone OUTCAR: Extracts 2 pseudopotentials with complete metadata
Extract lambda into parse_potcar_section() for better readability.
Maintains all existing functionality while improving maintainability.

Testing confirms both scenarios work correctly:
- vasprun.xml + OUTCAR: 2 PPs with complete metadata
- Standalone OUTCAR: 2 PPs with complete metadata
Implements field-level transformer pattern for modular per-instance field
derivations, addressing PR #144 feedback.

Changes:
- Refactored `get_pseudopotentials` to passthrough `get_raw_pseudopotentials`
- Added field-level transformers: `to_float()`, `to_int()`, `derive_pp_type()`,
  `derive_is_norm_conserving()`, `derive_is_gw_optimized()`
- Changed transformers from list-typed to scalar (per-instance) signatures
- Moved collection annotation to after `Pseudopotential` class definition so
  custom VASP class with extra fields is instantiated (not base class)
- Converted field annotations to relative paths (`.TITEL` not `@.pseudopotentials[*].TITEL`)
- Added multi-path field transformer annotations: `('derive_pp_type', ['.LPAW', '.LULTRA', '.TITEL'])`
- Moved `approx()` test helper to shared `tests/parsers/utils.py`

Technical: Field transformers called once per instance with scalar values.
Framework extracts multiple paths per instance and passes as positional args.

Tests pass: Both vasprun.xml and standalone OUTCAR produce complete metadata.
Changes `parse_potcar_section()` to return `None` for header-only POTCAR
lines (instead of empty dict). Framework skips `None` values automatically,
eliminating need for `get_raw_pseudopotentials()` filtering transformer.

Changes:
- outcar_parser.py: Return `None` from `parse_potcar_section()` for
  header-only lines, remove `get_raw_pseudopotentials()` transformer
- vasp.py: Change collection annotation from
  `('get_raw_pseudopotentials', ['@.pseudopotentials'])` to direct path
  `'@.pseudopotentials'`

Result: Cleaner code, filtering handled at source by text parser.
Refactors POTCAR section parsing to use idiomatic TextParser sub-parser
pattern instead of manual regex extraction function.

Changes:
- outcar_parser.py: Replace `parse_potcar_section()` function with
  declarative `potcar_quantities` list defining field patterns
- Use `sub_parser=TextParser(quantities=potcar_quantities)` instead of
  `str_operation=parse_potcar_section`
- Update regex pattern to filter header-only POTCAR lines with positive
  lookahead: `(?=[\s\S]*?VRHFIN)` ensures VRHFIN present before capturing
- Add `dtype=int` to LMAX and LMMAX quantities for automatic type conversion

Benefits:
- More maintainable: Declarative quantity definitions vs manual parsing
- More idiomatic: Uses TextParser framework patterns
- Cleaner: ~40 lines of manual regex code eliminated
- Type-safe: Framework handles type conversions automatically

All tests pass.
Eliminates ~90 line manual traversal function by using JMESPath directly in
collection annotation for vasprun.xml pseudopotential parsing.

Changes:
- vasp.py: Replace `('get_pseudopotentials_xml', ['atominfo.array'])` with
  JMESPath expression: `atominfo.array[?@name=='atomtypes'] | [0].set.rc`
- vasp.py: Update XML field annotations to extract from c array structure:
  `.c[4]` for name, `('to_float', ['.c[3]'])` for valence electrons
- xml_parser.py: Add `to_float()` field-level transformer
- xml_parser.py: Remove `get_pseudopotentials_xml()` function entirely

Benefits:
- Declarative: JMESPath expression clearly shows data navigation
- Concise: ~90 lines of manual Python code eliminated
- Maintainable: No custom traversal logic to maintain
- Framework-native: Uses JMESPath and field-level transformers only

All tests pass.
…ments

Refactors schema package to follow idiomatic style from other parsers like
exciting.py. Quantities and their annotations are now co-located for clarity.

Changes:
- Group each custom quantity (sha256, lpaw, lultra, lexch, enmax, enmin) with
  its annotation immediately following the definition
- Simplify docstring from verbose paragraph to concise one-liner
- Remove noise comments: "dft method", "kspace numerical settings",
  "atomic cell", "value is already defined" explanations
- Keep TODOs (they provide useful context)
- Reduce Pseudopotential class from ~120 lines to ~70 lines

Style matches exciting.py and other parsers: minimal comments, co-located
definitions and annotations, clean and scannable.

All tests pass.
When vasprun.xml is the mainfile, OUTCAR now only supplements pseudopotential
metadata (SHA256, LPAW, LULTRA, ENMAX, etc.) instead of parsing all data.
This avoids redundant parsing since vasprun.xml already contains eigenvalues,
energies, and forces.

Changes:
- Create minimal `outcar_supplement_parser` with only pseudopotential quantity
- Fix POTCAR regex to require VRHFIN within matched content, not just ahead:
  `POTCAR:([\s\S]+?VRHFIN[\s\S]+?)(?=\s*POTCAR:|\s*local pseudopotential:|\Z)`
  This prevents matching header-only POTCAR lines that lack detailed metadata
- Simplify `_find_outcar()` to use generator expression with `next()`
- Update comments to reflect POTCAR-specific scope

Fixes issue where 4 pseudopotentials were parsed instead of 2 due to regex
matching both header-only lines and detailed sections.
Quantity('LEXCH', r'LEXCH\s*=\s*(\w+)'),
Quantity('ZVAL', r'POMASS\s*=\s*[\d\.]+;\s*ZVAL\s*=\s*([\d\.]+)'),
Quantity('RCORE', r'RCORE\s*=\s*([\d\.]+)'),
Quantity('ENMAX', r'ENMAX\s*=\s*([\d\.]+)'),

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.

specify the types here already so you do not need the conversion functions like to_float

outcar_path = self._find_outcar()
if outcar_path and os.path.exists(outcar_path):
LOGGER.info(
f'Found OUTCAR at {outcar_path}, extending vasprun.xml pseudopotentials '

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.

avoid formatted string pass outcar_path as extra

quantities=[
Quantity(
'pseudopotentials',
r'POTCAR:([\s\S]+?VRHFIN[\s\S]+?)(?=\s*POTCAR:|\s*local pseudopotential:|\Z)',

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.

maybe reuse the one defined in the outcar parser so we do not have then in two places

data_parser.close()
xml_parser.close()

def _find_outcar(self) -> str | None:

@ladinesa ladinesa Feb 6, 2026

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.

can't we use the search_file in utils instead? sorry just realized it now.

@ladinesa

ladinesa commented Feb 6, 2026

Copy link
Copy Markdown
Collaborator

I am ok with the general implementation now, just some more minor suggestions for improvement.

@ndaelman-hu ndaelman-hu force-pushed the refactor/idiomatic-vasp-pseudopotential-parsing branch from a1eabf2 to 18eab9c Compare March 23, 2026 14:17
- Split long regex patterns across multiple lines
- Add noqa comments for justified PLC0415 (imports inside function)
- Remove unused import in test_vasp_parser.py
- Fix import formatting

All VASP parser tests still pass (2 tests).
This commit fixes the broken pseudopotential extraction from vasprun.xml files.

## Root cause

The module-level annotation `Pseudopotential.m_def` with path `atominfo.array[...]` was not working because:
1. The JMESPath filter syntax was incorrect (`@name` should be `"@name"` with quotes)
2. More critically, the annotation lacked a parent context - the mapping parser didn't know these Pseudopotential instances should be added to `ModelMethod.numerical_settings`

## Solution

Added a custom mapper function `get_pseudopotentials()` to `VasprunParser` that extracts pseudopotential data from the `modeling.atominfo` section and returns it in a format the mapping parser can process. Updated the schema annotation to use this function with proper context.

## Changes

`xml_parser.py:78-84`
- Added `get_pseudopotentials()` method to extract PP data from vasprun.xml atominfo section

`vasp.py:360-367`
- Fixed pseudopotential annotation to use custom mapper function
- Changed path syntax from incorrect `@name` to correct `"@name"`
- Added comment explaining why custom mapper is needed (XML structure mismatch)

`test_vasp_parser.py:15-35`
- Added assertions to verify pseudopotentials are extracted with required fields
- Accounts for OUTCAR merge deduplication behavior

## Testing

- Both `test_vasprun()` and `test_outcar()` pass
- Pseudopotentials now extracted from vasprun.xml with `name` and `n_valence_electrons` populated
- OUTCAR merge continues to work, supplementing with detailed metadata
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