# Copyright 2024, UChicago Argonne, LLC
# All Rights Reserved
# Software Name: NEML2 -- the New Engineering material Model Library, version 2
# By: Argonne National Laboratory
# OPEN SOURCE LICENSE (MIT)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""``CSV<Type>`` user-tensor classes used by the verification suite.
Each class reads a CSV file (loaded lazily, cached per-path) and returns a
typed ``TensorWrapper`` whose leading axis indexes rows. The HIT options
mirror the C++ ``VTestTimeSeries`` API but read CSV rather than the
``.vtest`` text format (a one-time ``scripts/vtest_to_csv.py`` conversion
produced the CSVs).
Two column-selection modes:
* ``variable = 'foo'`` -- auto-builds column names by suffix (e.g. SR2
=> ``foo_xx`` ... ``foo_xy``). Used by the converted ``.vtest``-derived
files where every variable follows the suffix convention.
* ``column_names = 'a b c'`` -- explicit column list. Used by hand-authored
``reference.csv`` files (e.g. the traction-separation scenarios).
SR2 storage: the ``.vtest`` files explicitly declare "Mandel convention"
in their headers, so ``CSVSR2`` returns the stacked columns verbatim with
no sqrt(2) scaling. If a future CSV stores physical (non-Mandel) shear values,
add an ``apply_mandel = true`` HIT option here at that point.
"""
from __future__ import annotations
from functools import cache
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar
import nmhit
import torch
from ..factory import register_neml2_object
from ..schema import HitSchema, option
from ..types import SR2, WR2, Scalar, Vec
if TYPE_CHECKING:
from ..factory import _NativeInputFile
from ..types._base import TensorWrapper
@cache
def _load_csv(path: Path) -> dict[str, torch.Tensor]:
"""Read *path* once and return a dict of column-name -> 1-D float64 tensor.
Uses the stdlib ``csv`` module to avoid pulling pandas into the import
chain just for a few-hundred-row reference file.
"""
import csv as _csv
with path.open() as f:
reader = _csv.reader(f)
header = next(reader)
rows = list(reader)
cols: dict[str, list[float]] = {name: [] for name in header}
for row in rows:
if len(row) != len(header):
raise ValueError(f"{path}: row has {len(row)} fields but header has {len(header)}")
for name, value in zip(header, row, strict=True):
cols[name].append(float(value))
return {name: torch.tensor(vals, dtype=torch.float64) for name, vals in cols.items()}
def _resolve_csv_path(node: nmhit.Node, factory: _NativeInputFile) -> Path:
raw = node.param_str("csv_file")
path = Path(raw)
if not path.is_absolute():
path = factory._path.resolve().parent / path
if not path.exists():
raise FileNotFoundError(f"CSV file not found: {path}")
return path
def _read_optional_column_names(node: nmhit.Node) -> list[str] | None:
"""Read the optional ``column_names`` HIT param as a list of tokens."""
raw = node.param_optional_str("column_names", "")
if not raw:
return None
return raw.split()
def _select_columns(
cols: dict[str, torch.Tensor],
names: list[str],
csv_path: Path,
) -> list[torch.Tensor]:
missing = [n for n in names if n not in cols]
if missing:
raise KeyError(f"{csv_path}: missing column(s) {missing}; available: {sorted(cols)}")
return [cols[n] for n in names]
class _CSVTensorBase:
"""Shared HIT-parsing scaffolding for the CSV<Type> classes.
Subclasses set ``TYPE_NAME`` (registry key), ``WRAPPER`` (the typed
``TensorWrapper`` class), and override ``_default_column_names`` to
expand a ``variable`` prefix into per-component column names.
"""
TYPE_NAME: ClassVar[str]
WRAPPER: ClassVar[type[TensorWrapper]]
SECTION: ClassVar[str] = "Tensors"
# Shared documentation schema — every CSV<Type> subclass inherits the same
# HIT surface via :meth:`from_hit`. ``variable`` and ``column_names`` are
# mutually exclusive (validated at parse time) but the schema can't express
# that constraint; both are declared optional and the constructor enforces
# the rule.
hit = HitSchema(
option(
"csv_file",
str,
"Path to the CSV file. Resolved relative to the input file's directory when "
"not absolute.",
),
option(
"variable",
str,
"Column-name prefix expanded via the per-type suffix convention (e.g. "
"``stress`` -> ``stress_xx``, ``stress_yy``, ... for SR2). Mutually exclusive "
"with ``column_names``.",
default="",
),
option(
"column_names",
list,
"Explicit whitespace-separated list of CSV column names; bypasses the "
"``variable`` suffix expansion. Mutually exclusive with ``variable``.",
default=[],
),
)
@classmethod
def _default_column_names(cls, variable: str) -> list[str]:
raise NotImplementedError
@classmethod
def from_hit(cls, node: nmhit.Node, factory: _NativeInputFile) -> TensorWrapper:
csv_path = _resolve_csv_path(node, factory)
cols = _load_csv(csv_path)
explicit = _read_optional_column_names(node)
variable = node.param_optional_str("variable", "")
if explicit is not None and variable:
raise ValueError(
f"{cls.TYPE_NAME}: specify either 'variable' or 'column_names', not both "
f"(at {csv_path})"
)
if explicit is not None:
names = explicit
elif variable:
names = cls._default_column_names(variable)
else:
raise ValueError(
f"{cls.TYPE_NAME}: must specify 'variable' or 'column_names' (at {csv_path})"
)
columns = _select_columns(cols, names, csv_path)
return cls._build(columns)
@classmethod
def _build(cls, columns: list[torch.Tensor]) -> TensorWrapper:
raise NotImplementedError
[docs]
@register_neml2_object("CSVScalar")
class CSVScalar(_CSVTensorBase):
"""Load a single column from CSV as a ``Scalar`` with shape ``(N,)``."""
TYPE_NAME = "CSVScalar"
WRAPPER = Scalar
@classmethod
def _default_column_names(cls, variable: str) -> list[str]:
return [variable]
@classmethod
def _build(cls, columns: list[torch.Tensor]) -> Scalar:
if len(columns) != 1:
raise ValueError(f"CSVScalar expects exactly 1 column, got {len(columns)}")
return Scalar(columns[0])
[docs]
@register_neml2_object("CSVSR2")
class CSVSR2(_CSVTensorBase):
"""Load 6 columns from CSV as an ``SR2`` with shape ``(N, 6)``.
Column-name suffix order matches the C++ ``VTestTimeSeries<SR2>`` /
Mandel slot convention: ``var_xx, var_yy, var_zz, var_yz, var_xz, var_xy``.
Values are stacked verbatim -- ``.vtest`` files declare Mandel convention
so the on-disk columns already carry the sqrt(2) scaling on shear slots.
"""
TYPE_NAME = "CSVSR2"
WRAPPER = SR2
_SUFFIXES = ("xx", "yy", "zz", "yz", "xz", "xy")
@classmethod
def _default_column_names(cls, variable: str) -> list[str]:
return [f"{variable}_{s}" for s in cls._SUFFIXES]
@classmethod
def _build(cls, columns: list[torch.Tensor]) -> SR2:
if len(columns) != 6:
raise ValueError(f"CSVSR2 expects exactly 6 columns, got {len(columns)}")
return SR2(torch.stack(columns, dim=-1))
[docs]
@register_neml2_object("CSVVec")
class CSVVec(_CSVTensorBase):
"""Load 3 columns from CSV as a ``Vec`` with shape ``(N, 3)``."""
TYPE_NAME = "CSVVec"
WRAPPER = Vec
_SUFFIXES = ("x", "y", "z")
@classmethod
def _default_column_names(cls, variable: str) -> list[str]:
return [f"{variable}_{s}" for s in cls._SUFFIXES]
@classmethod
def _build(cls, columns: list[torch.Tensor]) -> Vec:
if len(columns) != 3:
raise ValueError(f"CSVVec expects exactly 3 columns, got {len(columns)}")
return Vec(torch.stack(columns, dim=-1))
[docs]
@register_neml2_object("CSVWR2")
class CSVWR2(_CSVTensorBase):
"""Load 3 columns from CSV as a ``WR2`` (skew) with shape ``(N, 3)``.
Column-name suffix order mirrors the C++ ``VTestTimeSeries<WR2>`` call:
``var_zy, var_xz, var_yx``. WR2 has no Mandel scaling -- values are
stacked verbatim.
"""
TYPE_NAME = "CSVWR2"
WRAPPER = WR2
_SUFFIXES = ("zy", "xz", "yx")
@classmethod
def _default_column_names(cls, variable: str) -> list[str]:
return [f"{variable}_{s}" for s in cls._SUFFIXES]
@classmethod
def _build(cls, columns: list[torch.Tensor]) -> WR2:
if len(columns) != 3:
raise ValueError(f"CSVWR2 expects exactly 3 columns, got {len(columns)}")
return WR2(torch.stack(columns, dim=-1))
__all__ = ["CSVScalar", "CSVSR2", "CSVVec", "CSVWR2"]