############################################################
#
# Author(s): Georg Schnabel
# Email: g.schnabel@iaea.org
# Creation date: 2026/05/15
# Last modified: 2026/05/16
# License: MIT
# Copyright (c) 2026 International Atomic Energy Agency (IAEA)
#
############################################################
"""Material-qualified addressing for multi-material ENDF files.
An :class:`EndfMaterialPath` extends an :class:`EndfPath` with a leading
*material selector* so that a location can be addressed across a whole
tape. The two grammars are kept separate (design decision D13):
``EndfPath`` is unchanged and still addresses a location within one
material's parsed data; ``EndfMaterialPath`` prepends a material
selector to it.
Material selector grammar -- ``#`` selects the zero-based k-th member of
the candidate set to its left:
* ``MAT`` -- the unique material with that MAT number
* ``MAT#k`` -- the k-th material with that MAT number (PENDF tapes
repeat a MAT number, once per temperature)
* ``#k`` -- the material at tape position k
The selector is followed by an ordinary ``EndfPath``, so
``9237#1/3/2/xstable`` selects the second material with MAT 9237 and the
``xstable`` field of its MF=3/MT=2 section.
"""
from ..utils.accessories import EndfPath
from .errors import AmbiguousMaterialError
[docs]
class EndfMaterialPath:
"""A material selector followed by an :class:`EndfPath`.
A material-qualified path has the form ``material/MF/MT/field...``.
The leading *material selector* picks one material of a tape, and
the remaining ``/MF/MT/field...`` part is an ordinary
:class:`EndfPath` addressing data within that material. That part
may be truncated: a path may stop at the material, at a section
(``material/MF/MT``), or reach down to an individual field.
The material selector takes one of three forms:
- ``MAT`` selects the material with the given MAT number, e.g.
``2925``. Resolving this form fails with an
:class:`~endf_parserpy.tape.AmbiguousMaterialError` when the MAT
number is not unique, as it is on a PENDF tape.
- ``MAT#k`` selects the ``k``-th material carrying that MAT number,
e.g. ``9237#1``. This disambiguates a repeated MAT number; the
index ``k`` is zero-based, so ``9237#0`` is the first occurrence.
- ``#k`` selects the material at tape position ``k``, e.g. ``#0``
for the first material on the tape. The index ``k`` is zero-based.
Parameters
----------
pathspec : str or EndfMaterialPath
The path. For example, ``"9237#1/3/2/TEMP"`` addresses the
``TEMP`` field of the MF=3/MT=2 section of the second material
with MAT 9237, ``"#0/1/451"`` the whole MF=1/MT=451 section of
the first material on the tape, and ``"2925/3/1"`` the
MF=3/MT=1 section of the material with MAT 2925.
Attributes
----------
mf, mt : int or None
The section addressed by the path, or ``None`` if the path stops
at material level.
subpath : EndfPath or None
The path *within* the parsed section, or ``None`` for the whole
section.
"""
def __init__(self, pathspec):
if isinstance(pathspec, EndfMaterialPath):
self.__dict__.update(pathspec.__dict__)
return
self._spec = str(pathspec).strip()
parts = self._spec.strip("/").split("/")
if parts == [""]:
raise ValueError("an EndfMaterialPath must not be empty")
self._parse_material(parts[0])
rest = parts[1:]
try:
self.mf = int(rest[0]) if len(rest) >= 1 else None
self.mt = int(rest[1]) if len(rest) >= 2 else None
except ValueError:
raise ValueError(
f"invalid material path {self._spec!r}: MF and MT must be integers"
) from None
self.subpath = EndfPath("/".join(rest[2:])) if len(rest) > 2 else None
def _parse_material(self, token):
try:
if token.startswith("#"):
self._kind = "position"
self._position = int(token[1:])
self._mat = None
self._occurrence = None
if self._position < 0:
raise ValueError
elif "#" in token:
mat_str, occ_str = token.split("#", 1)
self._kind = "mat"
self._mat = int(mat_str)
self._occurrence = int(occ_str)
self._position = None
if self._occurrence < 0:
raise ValueError
else:
self._kind = "mat"
self._mat = int(token)
self._occurrence = None
self._position = None
except ValueError:
raise ValueError(
f"invalid material selector {token!r}; expected MAT, MAT#k "
"or #k with non-negative integers"
) from None
[docs]
def resolve_material(self, index):
"""Resolve the material selector against a :class:`TapeIndex`.
Returns the zero-based position of the selected material.
"""
if self._kind == "position":
if not 0 <= self._position < len(index):
raise IndexError(
f"material position {self._position} out of range; the "
f"tape has {len(index)} materials"
)
return self._position
positions = index.by_mat(self._mat)
if not positions:
raise KeyError(f"no material with MAT={self._mat}")
if self._occurrence is None:
if len(positions) > 1:
raise AmbiguousMaterialError(
f"MAT={self._mat} matches {len(positions)} materials at "
f"positions {positions}; use MAT#k to select one"
)
return positions[0]
if not 0 <= self._occurrence < len(positions):
raise IndexError(
f"occurrence {self._occurrence} out of range; MAT={self._mat} "
f"matches {len(positions)} materials"
)
return positions[self._occurrence]
def __repr__(self):
return f"EndfMaterialPath({self._spec!r})"
def __eq__(self, other):
if isinstance(other, EndfMaterialPath):
return self._spec == other._spec
return NotImplemented
def __hash__(self):
return hash(self._spec)
def parse_section_path(spec):
"""Parse a section-relative path ``"MF/MT[/field...]"``.
Returns ``(mf, mt, subpath)`` where ``subpath`` is an
:class:`EndfPath` or ``None`` (meaning the whole section). Used by
the bulk query operations, which apply the same section path to
every material.
"""
parts = str(spec).strip().strip("/").split("/")
if len(parts) < 2 or parts[0] == "":
raise ValueError(
f"section path {spec!r} must have at least MF/MT, e.g. '1/451'"
)
try:
mf = int(parts[0])
mt = int(parts[1])
except ValueError:
raise ValueError(f"section path {spec!r}: MF and MT must be integers") from None
subpath = EndfPath("/".join(parts[2:])) if len(parts) > 2 else None
return mf, mt, subpath
def parse_index_spec(spec):
"""Parse a :meth:`EndfFile.build_index` path specification.
``spec`` is either a single section path string (the single-field
index) or a list/tuple of them (a composite index). Returns
``(specs, is_multi)`` where ``specs`` is a list of ``(mf, mt,
subpath)`` triples -- as produced by :func:`parse_section_path` --
and ``is_multi`` records whether a composite, tuple-keyed index was
requested. The distinction is by argument *type*, so a one-element
list still counts as multi.
"""
if isinstance(spec, str):
return [parse_section_path(spec)], False
paths = list(spec)
if not paths:
raise ValueError("build_index needs at least one section path")
return [parse_section_path(p) for p in paths], True
def section_has(section, subpath):
"""Return whether ``subpath`` is present within a parsed section."""
if subpath is None:
return True
return subpath.exists(section)
def walk_section(section, subpath):
"""Return the value at ``subpath`` within a parsed section.
``subpath`` is an :class:`EndfPath`, or ``None`` for the whole
section.
"""
if subpath is None:
return section
return subpath.get(section)